diff --git a/README.md b/README.md index bf7491d..d5e814a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # KT Workflow MCP -`ktWorkflow` 是 KT 工作区的可复用 MCP 能力包,用来把根目录 `AGENTS.md`、`SKILLS.md`、`TASKS.md` 中的协作流程变成可调用工具。 +`ktWorkflow` 是 KT 工作区的可复用 MCP 能力包,用 TypeScript 封装工具入参、项目别名和返回结构,用来把根目录 `AGENTS.md`、`SKILLS.md`、`TASKS.md` 中的协作流程变成可调用工具。 ## 能力边界 @@ -21,6 +21,7 @@ ```bash cd D:/MyFiles/KT/mcp/ktWorkflow pnpm install +pnpm run typecheck pnpm run self-test pnpm run cleanup-history -- --dry-run ``` @@ -70,9 +71,16 @@ pnpm run cleanup-history -- --dry-run | `mcp` | `mcp/ktWorkflow` | | `api` | `Node/kt-template-online-api` | | `admin` | `Vue/kt-template-admin` | +| `blog` | `Vue/kt-blog-web` | | `web` | `Vue/kt-template-online-web` | | `playground` | `Vue/kt-template-online-playground` | +## 开发说明 + +- 主入口是 `src/server.ts`,工具入参类型集中在 `src/types.ts`。 +- `src/server.mjs` 只做兼容启动,保留给 MCP 客户端继续使用原来的 `node .../src/server.mjs` 配置。 +- 如果本机默认 `node` 不是 20+,可通过 `KT_WORKFLOW_NODE` 指定 Node 22 路径。 + ## 使用建议 - 写代码前先调用 `kt_prepare_task`;只需要单项信息时再调用 `kt_read_context` 或 `kt_inspect_project`。 diff --git a/package.json b/package.json index d127c78..b94a619 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "scripts": { "cleanup-history": "node src/server.mjs --cleanup-history", "self-test": "node src/server.mjs --self-test", - "start": "node src/server.mjs" + "start": "node src/server.mjs", + "typecheck": "tsc --noEmit" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.20.0", @@ -19,5 +20,10 @@ "engines": { "node": ">=20.19.0" }, - "packageManager": "pnpm@10.28.2" + "packageManager": "pnpm@10.28.2", + "devDependencies": { + "@types/node": "^25.9.1", + "tsx": "^4.22.3", + "typescript": "^6.0.3" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8b992b..87cab1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,175 @@ importers: zod: specifier: ^3.25.0 version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + tsx: + specifier: ^4.22.3 + version: 4.22.3 + typescript: + specifier: ^6.0.3 + version: 6.0.3 packages: + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -33,6 +199,9 @@ packages: '@cfworker/json-schema': optional: true + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -128,6 +297,11 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -171,6 +345,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -359,10 +538,23 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -389,6 +581,84 @@ packages: snapshots: + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + '@hono/node-server@1.19.14(hono@4.12.21)': dependencies: hono: 4.12.21 @@ -415,6 +685,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -502,6 +776,35 @@ snapshots: dependencies: es-errors: 1.3.0 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escape-html@1.0.3: {} etag@1.8.1: {} @@ -569,6 +872,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-intrinsic@1.3.0: @@ -760,12 +1066,22 @@ snapshots: toidentifier@1.0.1: {} + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-is@2.1.0: dependencies: content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 + typescript@6.0.3: {} + + undici-types@7.24.6: {} + unpipe@1.0.0: {} vary@1.1.2: {} diff --git a/src/server.mjs b/src/server.mjs index 8a9172d..61eca8d 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -1,1105 +1,42 @@ #!/usr/bin/env node -import { execFile } from 'node:child_process'; -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; - -const execFileAsync = promisify(execFile); const dirname = path.dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = path.resolve( - process.env.KT_WORKSPACE_ROOT || path.join(dirname, '../../..'), -); +const serverEntry = path.join(dirname, 'server.ts'); +const minimumNodeMajor = 20; -const projectAliases = { - admin: 'Vue/kt-template-admin', - api: 'Node/kt-template-online-api', - mcp: 'mcp/ktWorkflow', - playground: 'Vue/kt-template-online-playground', - root: '.', - web: 'Vue/kt-template-online-web', -}; - -const projectLabels = { - admin: 'Vue Admin 后台', - api: 'NestJS API 后端', - mcp: 'KT Workflow MCP', - playground: 'Playground 编辑器', - root: 'KT 协作根目录', - web: 'Web 前台展示', -}; - -const taskTypeValues = [ - 'api', - 'backend', - 'commit', - 'database', - 'deploy', - 'docs', - 'frontend', - 'general', - 'mcp', - 'page', - 'style', -]; - -const registeredToolNames = [ - 'kt_read_context', - 'kt_inspect_project', - 'kt_inspect_all_projects', - 'kt_guardrails', - 'kt_prepare_task', - 'kt_suggest_verification', - 'kt_create_page_test_case', - 'kt_api_test_plan', - 'kt_cleanup_history', - 'kt_cleanup_process_plan', - 'kt_env_policy', - 'kt_risk_scan', - 'kt_append_task_record', - 'kt_commit_checklist', -]; - -const defaultHistoryRoots = [ - 'test-artifacts', - '.codex-test-logs', - 'codex-test-logs', - '.codex-verify', - '.playwright-mcp', - '.codex-db-sync', -]; - -function toPosix(value) { - return value.replaceAll(path.sep, '/'); +function getNodeMajor() { + return Number.parseInt(process.versions.node.split('.')[0] || '0', 10); } -function resolveInsideRoot(target = '.') { - const absoluteTarget = path.isAbsolute(target) - ? path.resolve(target) - : path.resolve(workspaceRoot, target); - const relative = path.relative(workspaceRoot, absoluteTarget); - - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new Error(`Path is outside KT workspace: ${target}`); +function getNodeBinary() { + if (process.env.KT_WORKFLOW_NODE && existsSync(process.env.KT_WORKFLOW_NODE)) { + return process.env.KT_WORKFLOW_NODE; } - return absoluteTarget; -} - -function resolveProject(project = 'root') { - const normalized = projectAliases[project] || project; - const absolutePath = resolveInsideRoot(normalized); - const alias = Object.entries(projectAliases).find( - ([, relativePath]) => resolveInsideRoot(relativePath) === absolutePath, - )?.[0]; - - return { - alias: alias || null, - label: alias ? projectLabels[alias] : '自定义路径', - path: absolutePath, - relativePath: toPosix(path.relative(workspaceRoot, absolutePath)) || '.', - }; -} - -function readText(relativePath, fallback = '') { - const filePath = resolveInsideRoot(relativePath); - if (!existsSync(filePath)) return fallback; - return readFileSync(filePath, 'utf8'); -} - -function readTextIfExists(filePath, fallback = '') { - if (!existsSync(filePath)) return fallback; - return readFileSync(filePath, 'utf8'); -} - -function readJson(filePath) { - if (!existsSync(filePath)) return null; - - try { - return JSON.parse(readFileSync(filePath, 'utf8')); - } catch { - return null; + if (getNodeMajor() >= minimumNodeMajor) { + return process.execPath; } -} -function listImmediateChildren(targetPath) { - if (!existsSync(targetPath)) return []; - - return readdirSync(targetPath, { withFileTypes: true }).map((item) => ({ - name: item.name, - type: item.isDirectory() ? 'directory' : 'file', - })); -} - -function getRecentTaskRecords(count = 5) { - const tasks = readText('TASKS.md'); - const records = tasks - .split(/\n(?=### \d{4}-\d{2}-\d{2}:)/) - .filter((section) => section.startsWith('### ')) - .slice(0, count); - - return records.join('\n\n').trim(); -} - -async function tryExecFile(command, args, cwd) { - try { - const result = await execFileAsync(command, args, { - cwd, - maxBuffer: 1024 * 1024 * 4, - timeout: 20_000, - windowsHide: true, - }); - return { - ok: true, - stderr: result.stderr.trim(), - stdout: result.stdout.trim(), - }; - } catch (error) { - return { - ok: false, - stderr: `${error.stderr || error.message || ''}`.trim(), - stdout: `${error.stdout || ''}`.trim(), - }; - } -} - -function detectRepoType(projectPath) { - if (existsSync(path.join(projectPath, '.git'))) return 'git'; - if (existsSync(path.join(projectPath, '.svn'))) return 'svn'; - return 'none'; -} - -function detectPackageManager(projectPath) { - const packageJsonPath = path.join(projectPath, 'package.json'); - const packageJson = readJson(packageJsonPath); - const lockfiles = { - npm: existsSync(path.join(projectPath, 'package-lock.json')), - pnpm: existsSync(path.join(projectPath, 'pnpm-lock.yaml')), - yarn: existsSync(path.join(projectPath, 'yarn.lock')), - }; - const packageManagerSpec = packageJson?.packageManager || null; - const packageManager = - packageManagerSpec?.split('@')[0] || - (lockfiles.pnpm && 'pnpm') || - (lockfiles.yarn && 'yarn') || - (lockfiles.npm && 'npm') || - null; - - return { - engines: packageJson?.engines || {}, - lockfiles, - packageManager, - packageManagerSpec, - scripts: packageJson?.scripts || {}, - }; -} - -async function inspectProject(input) { - const project = resolveProject(input.project); - const repoType = detectRepoType(project.path); - const packageInfo = detectPackageManager(project.path); - const nodeVersionFile = ['.node-version', '.nvmrc'].find((file) => - existsSync(path.join(project.path, file)), - ); - const nodeVersion = nodeVersionFile - ? readFileSync(path.join(project.path, nodeVersionFile), 'utf8').trim() - : null; - const envFiles = ['.env.development', '.env.production', '.env.example'].map( - (file) => ({ - exists: existsSync(path.join(project.path, file)), - file, - }), - ); - const childSummary = listImmediateChildren(project.path).slice(0, 60); - const git = - repoType === 'git' && input.includeGit !== false - ? { - branch: await tryExecFile('git', ['branch', '--show-current'], project.path), - remote: await tryExecFile('git', ['remote', '-v'], project.path), - status: await tryExecFile('git', ['status', '--short'], project.path), - } - : null; - - return { - childSummary, - envFiles, - git, - nodeVersion, - nodeVersionFile: nodeVersionFile || null, - packageInfo, - project, - repoType, - }; -} - -function getTypecheckCommand(project, packageManager, scripts) { - if (project.alias === 'admin') { - return 'pnpm -F @vben/web-antdv-next run typecheck'; - } - if (project.alias === 'web') { - return 'pnpm exec vue-tsc --noEmit'; - } - if (scripts.typecheck) return `${packageManager} run typecheck`; - if (scripts['check:type']) return `${packageManager} run check:type`; - if (scripts.check) return `${packageManager} run check`; - return null; -} - -function buildVerificationPlan(input) { - const project = resolveProject(input.project); - const repoType = detectRepoType(project.path); - const packageInfo = detectPackageManager(project.path); - const packageManager = packageInfo.packageManager || 'pnpm'; - const scripts = packageInfo.scripts; - const changeType = input.changeType || 'general'; - const commands = repoType === 'git' ? ['git status --short'] : []; - const notes = [ - '先确认仓库类型,再确认包管理器和 Node 版本。', - '只跑当前变更所需的最轻验证,不默认全量 build。', + const candidates = [ + 'C:\\Program Files (x86)\\nvm\\v22.14.0\\node.exe', + 'C:\\Program Files\\nodejs\\node.exe', ]; - const typecheck = getTypecheckCommand(project, packageManager, scripts); - - if (changeType === 'style' && project.alias === 'admin') { - commands.push( - 'pnpm exec stylelint "apps/web-antdv-next/src/components/**/*.{scss,css,vue}"', - ); - } - - if (repoType === 'none') { - notes.push('当前目录不是独立 Git/SVN 仓库,提交和 diff 检查需要切到对应子仓库执行。'); - } - - if ( - typecheck && - ['api', 'frontend', 'general', 'page', 'style'].includes(changeType) - ) { - commands.push(typecheck); - } - - if (project.alias === 'api') { - if (scripts.lint) commands.push(`${packageManager} run lint`); - if (scripts.test && ['api', 'backend', 'general'].includes(changeType)) { - commands.push(`${packageManager} test -- --passWithNoTests`); - } - if (['api', 'backend'].includes(changeType)) { - notes.push('接口改动需要本地启动或复用本地服务,真实调用对应接口一次。'); - } - } - - if (project.alias === 'playground' && scripts['build-preview'] && changeType === 'deploy') { - commands.push(`${packageManager} run build-preview`); - } - - if (project.alias === 'web' && scripts.build && changeType === 'deploy') { - commands.push(`${packageManager} run build`); - } - - if (project.alias === 'mcp') { - if (scripts['self-test']) commands.push(`${packageManager} run self-test`); - notes.push('MCP 变更需要用真实 SDK Client 做 listTools/callTool smoke test。'); - } - - if (input.includePageTest || changeType === 'page') { - notes.push( - '页面级测试必须先写测试用例,使用可见浏览器或截图记录关键步骤,同一用例最多三轮闭环。', - ); - } - - if (repoType === 'git') commands.push('git diff --check'); - - return { - changeType, - commands: Array.from(new Set(commands)), - notes, - project, - repoType, - }; + return candidates.find((candidate) => existsSync(candidate)) || process.execPath; } -function createPageTestCase(input) { - const project = resolveProject(input.project); - const title = input.title || `${project.label} 页面级测试`; - const steps = - input.steps?.length > 0 - ? input.steps - : ['打开入口 URL', '完成登录或注入测试登录态', '执行用户关键路径操作', '保存关键步骤截图']; - const assertions = - input.assertions?.length > 0 - ? input.assertions - : ['页面无明显渲染错误', '控制台无 error', '关键接口返回成功', '核心 DOM 或业务状态符合预期']; - - return { - account: input.account || '按当前环境使用数据库管理员账号或测试专用账号', - assertions, - cleanup: [ - '关闭浏览器实例', - '清理本次启动的 Node/Vite 进程', - '保存截图和 result.json', - '调用 kt_cleanup_history,仅保留最近 3 轮历史产物', - ], - entryUrl: input.entryUrl || '待填写', - maxRounds: 3, - project, - protocol: [ - '测试前先写用例。', - '失败后先记录复现证据,再排查和修复。', - '按同一用例复测。', - '第三轮仍失败时停止并等待用户建议。', - ], - steps, - title, - }; -} - -async function inspectAllProjects(input = {}) { - const aliases = input.includeRoot === false - ? Object.keys(projectAliases).filter((key) => key !== 'root') - : Object.keys(projectAliases); - const inspections = await Promise.all( - aliases.map((alias) => - inspectProject({ - includeGit: input.includeGit !== false, - project: alias, - }), - ), - ); - - return { - inspections, - workspaceRoot, - }; -} - -function buildGuardrails(input) { - const project = resolveProject(input.project); - const taskType = input.taskType || input.changeType || 'general'; - const paths = input.paths || []; - const guardrails = { - beforeEdit: [ - '先读最近的 AGENTS.md,再读 TASKS.md 和 SKILLS.md。', - '先检查目标仓库状态,识别用户已有改动,不覆盖、不回滚无关文件。', - '先快速扫现有代码风格和目录结构,再决定改动方式。', - '搜索优先用 rg,路径和接口名尽量保持可 grep。', - ], - duringEdit: [ - '改动范围收敛到本次需求相关文件。', - '复杂度高的地方加简短注释,避免空泛解释。', - '真实环境配置不写入提交内容,优先只维护 .env.example。', - '前端实现遵循现有 antdv-next/Vben 约定,不引入无关抽象。', - ], - doNot: [ - '不要执行 git reset --hard、git checkout -- 等破坏性回滚。', - '不要在用户未要求时自动 push。', - '不要把真实密钥、数据库密码、生产 env 写进可提交文件。', - '不要为了验证长期保留 Node/Vite/浏览器进程。', - ], - verification: buildVerificationPlan({ - changeType: taskType, - includePageTest: input.includePageTest || taskType === 'page', - project: input.project, - }), - }; - - if (['api', 'backend'].includes(taskType)) { - guardrails.beforeEdit.push('接口改动先确认 DTO、Controller、Service、返回结构和鉴权链路。'); - guardrails.duringEdit.push('接口成功返回统一保持 code=200/msg/data,错误返回 err 字段。'); - guardrails.verification.notes.push('接口改完必须本地真实调用一次对应接口。'); - } - - if (taskType === 'page' || input.includePageTest) { - guardrails.beforeEdit.push('页面级测试先写用例,再打开可视化浏览器执行。'); - guardrails.verification.pageTestCase = createPageTestCase({ - project: input.project, - title: input.userRequest || `${project.label} 页面级测试`, - }); - } - - if (taskType === 'database') { - guardrails.beforeEdit.push('数据库同步或修复前先确认源库、目标库、备份库和回滚点。'); - guardrails.doNot.push('不要在未确认目标库前执行覆盖、drop、truncate。'); - } - - if (taskType === 'deploy') { - guardrails.duringEdit.push('部署配置保留真正能提升运行稳定性的内容,删掉噪音配置。'); - guardrails.doNot.push('不要在未要求时触发真实部署或改动远程服务。'); - } - - if (taskType === 'mcp') { - guardrails.duringEdit.push('MCP 工具描述要明确边界,默认输出建议和检查清单,不做高风险副作用。'); - guardrails.verification.notes.push('MCP 变更至少跑 self-test 和真实 SDK Client smoke test。'); - } - - return { - guardrails, - paths, - project, - taskType, - userRequest: input.userRequest || '', - }; -} - -function createApiTestPlan(input) { - const project = resolveProject(input.project || 'api'); - const method = (input.method || 'GET').toUpperCase(); - const endpoint = input.endpoint || '/api/待填写'; - const baseUrl = input.baseUrl || 'http://localhost:3000'; - const headers = []; - - if (input.auth === 'bearer') { - headers.push('-H "Authorization: Bearer "'); - } - - if (input.contentType !== false && ['POST', 'PUT', 'PATCH'].includes(method)) { - headers.push('-H "Content-Type: application/json"'); - } - - const body = - input.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) - ? ` -d '${JSON.stringify(input.body)}'` - : ''; - const curl = `curl -i -X ${method} ${headers.join(' ')} "${baseUrl}${endpoint}"${body}`; - - return { - assertions: [ - 'HTTP 状态码符合预期。', - '成功响应结构为 { code: 200, msg: string, data: any }。', - '成功响应不返回 err 字段。', - ...(input.expectedFields || []).map((field) => `data 中包含 ${field}`), - ], - curl, - endpoint, - method, - notes: [ - '接口改动后不要只跑类型检查,必须真实调用对应接口一次。', - '如依赖登录态,先调用 login 获取 token,再带 Authorization 复测。', - ], - project, - }; -} - -function createCleanupProcessPlan(input) { - const project = resolveProject(input.project); - const escapedProjectPath = project.path.replaceAll('\\', '\\\\'); - const portFilters = (input.ports || []).map((port) => ({ - command: `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`, - port, - })); - - return { - commands: [ - `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escapedProjectPath}*' -and $_.Name -match 'node|pnpm|npm|yarn' } | Select-Object ProcessId,Name,CommandLine`, - '确认命中进程只属于本次验证后,再用 Stop-Process -Id 结束。', - ...portFilters.map((item) => item.command), - ], - notes: [ - '先按项目路径筛选,再按端口交叉确认。', - '不要批量结束与本次验证无关的 Node 进程。', - '页面测试结束后清理浏览器和 Vite/Nest 进程,避免内存累积。', - ], - project, - }; -} - -function listHistoryEntries(rootPath, preservedNames) { - if (!existsSync(rootPath)) return []; - - return readdirSync(rootPath, { withFileTypes: true }) - .filter((item) => !preservedNames.has(item.name)) - .map((item) => { - const entryPath = path.join(rootPath, item.name); - const stats = statSync(entryPath); - - return { - lastWriteTime: stats.mtime.toISOString(), - mtimeMs: stats.mtimeMs, - name: item.name, - path: entryPath, - relativePath: toPosix(path.relative(workspaceRoot, entryPath)), - type: item.isDirectory() ? 'directory' : 'file', - }; - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs); -} - -function cleanupHistoryArtifacts(input = {}) { - const keep = Math.max(0, Math.min(input.keep ?? 3, 50)); - const dryRun = input.dryRun ?? true; - const roots = input.roots?.length > 0 ? input.roots : defaultHistoryRoots; - const result = { - deleted: [], - dryRun, - keep, - preserved: [], - roots: [], - skipped: [], - }; - - for (const root of roots) { - const rootPath = resolveInsideRoot(root); - const rootRelativePath = toPosix(path.relative(workspaceRoot, rootPath)) || '.'; - - if (!existsSync(rootPath)) { - result.skipped.push({ - reason: 'not_exists', - root: rootRelativePath, - }); - continue; - } - - const preservedNames = new Set(rootRelativePath === 'test-artifacts' ? ['_templates'] : []); - const entries = listHistoryEntries(rootPath, preservedNames); - const preserved = entries.slice(0, keep); - const deleting = entries.slice(keep); - - result.roots.push({ - candidates: entries.length, - deleteCount: deleting.length, - keepCount: preserved.length, - preservedNames: Array.from(preservedNames), - root: rootRelativePath, - }); - result.preserved.push(...preserved.map(({ mtimeMs, path: _path, ...entry }) => entry)); - - for (const entry of deleting) { - if (!dryRun) { - rmSync(entry.path, { - force: true, - recursive: true, - }); - } - const { mtimeMs, path: _path, ...publicEntry } = entry; - result.deleted.push(publicEntry); - } - } - - return result; -} - -function parseCliCleanupArgs(args) { - const keepArg = args.find((arg) => arg.startsWith('--keep=')); - const rootsArg = args.find((arg) => arg.startsWith('--roots=')); - - return { - dryRun: args.includes('--dry-run'), - keep: keepArg ? Number.parseInt(keepArg.split('=')[1], 10) : 3, - roots: rootsArg ? rootsArg.split('=')[1].split(',').filter(Boolean) : defaultHistoryRoots, - }; -} - -function buildEnvPolicy(input) { - const project = resolveProject(input.project); - const envFiles = ['.env.development', '.env.production', '.env.example'].map((file) => { - const absolutePath = path.join(project.path, file); - return { - exists: existsSync(absolutePath), - file, - relativePath: toPosix(path.relative(workspaceRoot, absolutePath)), - }; - }); - const gitignore = readTextIfExists(path.join(project.path, '.gitignore')); - const ignoredRules = gitignore - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - - return { - envFiles, - ignoredRules: ignoredRules.filter((line) => line.includes('.env')), - policy: [ - '真实开发和生产配置只保留在本地,不提交。', - '.env.example 用于维护变量名和示例值。', - '接口、数据库、WordPress、MinIO 等真实账号密码不得写入文档或提交记录。', - ], - project, - warnings: envFiles - .filter((file) => file.file !== '.env.example' && file.exists) - .map((file) => `${file.file} 存在,提交前确认已被 .gitignore 忽略。`), - }; -} - -async function scanTaskRisk(input) { - const project = resolveProject(input.project); - let changedFiles = input.changedFiles || []; - - if (changedFiles.length === 0 && detectRepoType(project.path) === 'git') { - const status = await tryExecFile('git', ['status', '--short'], project.path); - if (status.ok) { - changedFiles = status.stdout - .split(/\r?\n/) - .map((line) => line.slice(3).trim()) - .filter(Boolean); - } - } - - const risks = []; - const addRisk = (level, file, reason, suggestion) => { - risks.push({ file, level, reason, suggestion }); - }; - - for (const file of changedFiles) { - const normalized = file.replaceAll('\\', '/'); - - if (/\.env\.(development|production)$/.test(normalized)) { - addRisk('high', file, '真实环境配置文件有改动。', '确认不会提交真实密钥和密码。'); - } - - if (/package\.json$|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$/.test(normalized)) { - addRisk('medium', file, '依赖或脚本发生变化。', '确认锁文件与 package.json 同步,并运行对应轻量验证。'); - } - - if (normalized.includes('/components/ktTable/')) { - addRisk( - 'medium', - file, - 'KtTable 是高频核心组件,布局和滚动性能容易回归。', - '保持原生 antdv-next 表格能力,避免监听 scroll/resize 做高频状态更新。', - ); - } - - if (/src\/admin\/|src\/.*controller|src\/.*service|src\/.*dto/.test(normalized)) { - addRisk('medium', file, '后端接口或业务逻辑改动。', '本地启动或复用服务后真实调用对应接口。'); - } - - if (/Jenkinsfile|deploy\/|k8s\/|nginx.*\.conf/.test(normalized)) { - addRisk('medium', file, '部署链路配置改动。', '只验证配置语法或生成产物,不主动触发远程部署。'); - } - } - - return { - changedFiles, - project, - risks, - safeDefaults: [ - '不覆盖用户已有改动。', - '只运行必要验证。', - '提交前重新检查 status 和 diff。', - ], - }; -} - -async function prepareTask(input) { - const project = resolveProject(input.project); - const taskType = input.taskType || 'general'; - const inspection = await inspectProject({ - includeGit: input.includeGit !== false, - project: input.project, - }); - const guardrails = buildGuardrails(input); - const riskScan = await scanTaskRisk({ - changedFiles: input.changedFiles || [], - project: input.project, - }); - const pageTestCase = - input.includePageTest || taskType === 'page' - ? createPageTestCase({ - entryUrl: input.entryUrl, - project: input.project, - title: input.userRequest || `${project.label} 页面级测试`, - }) - : null; - - return { - guardrails, - inspection, - nextSteps: [ - '阅读 workPacket.readFirst 中的上下文文件。', - '按 guardrails.beforeEdit 做开工前检查。', - '按最小范围修改。', - '按 verification.commands 和 notes 做轻量验证。', - '有文件改动时更新 TASKS.md。', - ], - pageTestCase, - project, - readFirst: [ - 'AGENTS.md', - 'SKILLS.md', - 'TASKS.md', - `${project.relativePath}/AGENTS.md`, - `${project.relativePath}/package.json`, - ], - riskScan, - taskRecordDraft: buildTaskRecord({ - content: input.userRequest || '待补充', - scope: [project.relativePath], - testCase: pageTestCase ? pageTestCase.title : '本次无页面测试或待补充。', - title: input.recordTitle || input.userRequest || `${project.label} 任务记录`, - verification: guardrails.guardrails.verification.commands.join(';') || '待补充。', - }), - taskType, - userRequest: input.userRequest || '', - }; -} - -function formatDateInShanghai(date = new Date()) { - const parts = new Intl.DateTimeFormat('en-CA', { - day: '2-digit', - month: '2-digit', - timeZone: 'Asia/Shanghai', - year: 'numeric', - }).formatToParts(date); - const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); - return `${map.year}-${map.month}-${map.day}`; -} - -function buildTaskRecord(input) { - const date = input.date || formatDateInShanghai(); - const scope = input.scope?.length > 0 ? input.scope.join('、') : '待补充'; - const content = input.content || '待补充'; - const testCase = input.testCase || '本次无页面测试或待补充。'; - const verification = input.verification || '待补充。'; - - return `### ${date}:${input.title}\n\n- 范围:${scope}\n- 内容:${content}\n- 页面测试用例:${testCase}\n- 验证:${verification}\n`; -} - -function appendTaskRecord(input) { - const record = buildTaskRecord(input); - const tasksPath = resolveInsideRoot('TASKS.md'); - const current = readFileSync(tasksPath, 'utf8'); - const marker = '## 改动记录'; - const markerIndex = current.indexOf(marker); - - if (markerIndex === -1) { - throw new Error('TASKS.md does not contain ## 改动记录'); - } - - const insertAt = current.indexOf('\n', markerIndex); - const next = `${current.slice(0, insertAt + 1)}\n${record}\n${current.slice(insertAt + 1)}`; - - if (!input.dryRun) { - writeFileSync(tasksPath, next, 'utf8'); - } - - return { - dryRun: input.dryRun, - record, - target: tasksPath, - }; -} - -async function createCommitChecklist(input) { - const inspection = await inspectProject({ - includeGit: true, - project: input.project, - }); - const message = input.message || ''; - const messageOk = - !message || - /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?: .*[一-龥]/u.test( - message, - ); - - return { - checks: [ - '确认只包含本次任务相关文件。', - '先运行对应轻量验证命令。', - '每个独立 Git 仓库单独 commit。', - '没有用户明确要求时不 push。', - ], - inspection, - message, - messageOk, - messageRule: '英文 type 前缀 + 中文描述,例如 feat: 功能性提交', - }; -} - -function readWorkflowContext(input) { - const projects = Object.keys(projectAliases).map((key) => { - const project = resolveProject(key); - return { - ...project, - exists: existsSync(project.path), - repoType: existsSync(project.path) ? detectRepoType(project.path) : 'missing', - }; - }); - - return { - docs: { - agents: readText('AGENTS.md'), - recentTasks: getRecentTaskRecords(input.taskRecordCount), - skills: readText('SKILLS.md'), - }, - projects, - workspaceRoot, - }; -} - -function response(data) { - return { - content: [ - { - text: typeof data === 'string' ? data : JSON.stringify(data, null, 2), - type: 'text', - }, - ], - }; -} - -async function runSelfTest() { - const data = { - context: readWorkflowContext({ taskRecordCount: 2 }), - guardrails: buildGuardrails({ - project: 'mcp', - taskType: 'mcp', - userRequest: '扩展 KT 工作区 MCP 可复用能力', - }), - historyCleanup: cleanupHistoryArtifacts({ - dryRun: true, - keep: 3, - }), - inspectAdmin: await inspectProject({ includeGit: false, project: 'admin' }), - prepareTask: await prepareTask({ - includeGit: false, - project: 'mcp', - taskType: 'mcp', - userRequest: '扩展 KT 工作区 MCP 可复用能力', - }), - verification: buildVerificationPlan({ - changeType: 'mcp', - project: 'mcp', - }), - }; - const outputDir = resolveInsideRoot('.codex-verify/ktWorkflow'); - mkdirSync(outputDir, { recursive: true }); - writeFileSync( - path.join(outputDir, 'self-test.json'), - JSON.stringify(data, null, 2), - 'utf8', - ); - console.log(JSON.stringify({ ok: true, outputDir, tools: registeredToolNames.length }, null, 2)); -} - -function registerTools(server) { - server.registerTool( - 'kt_read_context', - { - description: '读取 KT 根目录 AGENTS/SKILLS/TASKS,并返回项目清单和最近任务记录。', - inputSchema: { - taskRecordCount: z.number().int().min(0).max(20).default(5), - }, - title: 'Read KT Workflow Context', - }, - async (input) => response(readWorkflowContext(input)), - ); - - server.registerTool( - 'kt_inspect_project', - { - description: '检查 KT 子项目的仓库类型、包管理器、Node 版本、env 文件和 Git 状态。', - inputSchema: { - includeGit: z.boolean().default(true), - project: z.string().default('root'), - }, - title: 'Inspect KT Project', - }, - async (input) => response(await inspectProject(input)), - ); - - server.registerTool( - 'kt_inspect_all_projects', - { - description: '一次性检查 KT 工作区所有已知项目,适合多仓库联动任务开工前使用。', - inputSchema: { - includeGit: z.boolean().default(true), - includeRoot: z.boolean().default(true), - }, - title: 'Inspect All KT Projects', - }, - async (input) => response(await inspectAllProjects(input)), - ); - - server.registerTool( - 'kt_guardrails', - { - description: '按任务类型生成 KT 防偏差清单,包含开工、改动、禁止项和验证约束。', - inputSchema: { - changeType: z.enum(taskTypeValues).optional(), - includePageTest: z.boolean().default(false), - paths: z.array(z.string()).default([]), - project: z.string().default('root'), - taskType: z.enum(taskTypeValues).default('general'), - userRequest: z.string().optional(), - }, - title: 'KT Guardrails', - }, - async (input) => response(buildGuardrails(input)), - ); - - server.registerTool( - 'kt_prepare_task', - { - description: '生成一份 KT 任务 work packet:上下文、项目检查、防偏差清单、风险扫描和验证计划。', - inputSchema: { - changedFiles: z.array(z.string()).default([]), - entryUrl: z.string().optional(), - includeGit: z.boolean().default(true), - includePageTest: z.boolean().default(false), - paths: z.array(z.string()).default([]), - project: z.string().default('root'), - recordTitle: z.string().optional(), - taskType: z.enum(taskTypeValues).default('general'), - userRequest: z.string().optional(), - }, - title: 'Prepare KT Task', - }, - async (input) => response(await prepareTask(input)), - ); - - server.registerTool( - 'kt_suggest_verification', - { - description: '按 KT 规则为指定项目和变更类型生成轻量验证命令与注意事项。', - inputSchema: { - changeType: z.enum(taskTypeValues).default('general'), - includePageTest: z.boolean().default(false), - project: z.string().default('root'), - }, - title: 'Suggest KT Verification', - }, - async (input) => response(buildVerificationPlan(input)), - ); - - server.registerTool( - 'kt_create_page_test_case', - { - description: '生成 KT 页面级可视化测试用例骨架,包含事不过三闭环规则。', - inputSchema: { - account: z.string().optional(), - assertions: z.array(z.string()).default([]), - entryUrl: z.string().optional(), - project: z.string().default('admin'), - steps: z.array(z.string()).default([]), - title: z.string().optional(), - }, - title: 'Create KT Page Test Case', - }, - async (input) => response(createPageTestCase(input)), - ); - - server.registerTool( - 'kt_api_test_plan', - { - description: '为后端接口改动生成真实调用测试计划和响应结构断言。', - inputSchema: { - auth: z.enum(['none', 'bearer']).default('bearer'), - baseUrl: z.string().default('http://localhost:3000'), - body: z.record(z.any()).optional(), - contentType: z.boolean().default(true), - endpoint: z.string().default('/api/待填写'), - expectedFields: z.array(z.string()).default([]), - method: z.enum(['DELETE', 'GET', 'PATCH', 'POST', 'PUT']).default('GET'), - project: z.string().default('api'), - }, - title: 'KT API Test Plan', - }, - async (input) => response(createApiTestPlan(input)), - ); - - server.registerTool( - 'kt_cleanup_history', - { - description: '清理 KT 根目录历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个历史目录仅保留最近 3 轮,test-artifacts/_templates 永久保留。', - inputSchema: { - dryRun: z.boolean().default(true), - keep: z.number().int().min(0).max(50).default(3), - roots: z.array(z.string()).default(defaultHistoryRoots), - }, - title: 'KT Cleanup History Artifacts', - }, - async (input) => response(cleanupHistoryArtifacts(input)), - ); - - server.registerTool( - 'kt_cleanup_process_plan', - { - description: '生成按项目路径和端口清理验证进程的 PowerShell 检查命令,不直接杀进程。', - inputSchema: { - ports: z.array(z.number().int().min(1).max(65_535)).default([]), - project: z.string().default('root'), - }, - title: 'KT Cleanup Process Plan', - }, - async (input) => response(createCleanupProcessPlan(input)), - ); - - server.registerTool( - 'kt_env_policy', - { - description: '检查指定项目 env 文件现状,并输出 KT 环境配置提交策略。', - inputSchema: { - project: z.string().default('root'), - }, - title: 'KT Env Policy', - }, - async (input) => response(buildEnvPolicy(input)), - ); - - server.registerTool( - 'kt_risk_scan', - { - description: '按当前变更文件或传入文件列表扫描常见偏差风险和验证提醒。', - inputSchema: { - changedFiles: z.array(z.string()).default([]), - project: z.string().default('root'), - }, - title: 'KT Risk Scan', - }, - async (input) => response(await scanTaskRisk(input)), - ); - - server.registerTool( - 'kt_append_task_record', - { - description: '生成或写入 TASKS.md 改动记录。默认 dryRun=true,只预览不落盘。', - inputSchema: { - content: z.string().optional(), - date: z.string().optional(), - dryRun: z.boolean().default(true), - scope: z.array(z.string()).default([]), - testCase: z.string().optional(), - title: z.string(), - verification: z.string().optional(), - }, - title: 'Append KT Task Record', - }, - async (input) => response(appendTaskRecord(input)), - ); - - server.registerTool( - 'kt_commit_checklist', - { - description: '生成 KT 子仓库提交前检查清单,并校验 commit message 是否符合约定。', - inputSchema: { - message: z.string().optional(), - project: z.string().default('root'), - }, - title: 'KT Commit Checklist', - }, - async (input) => response(await createCommitChecklist(input)), - ); -} - -if (process.argv.includes('--cleanup-history')) { - console.log(JSON.stringify(cleanupHistoryArtifacts(parseCliCleanupArgs(process.argv)), null, 2)); -} else if (process.argv.includes('--self-test')) { - await runSelfTest(); -} else { - const server = new McpServer({ - name: 'kt-workflow', - version: '0.2.0', - }); - registerTools(server); - await server.connect(new StdioServerTransport()); -} +const result = spawnSync( + getNodeBinary(), + ['--import', 'tsx', serverEntry, ...process.argv.slice(2)], + { + env: process.env, + stdio: 'inherit', + windowsHide: true, + }, +); + +process.exit(result.status ?? 1); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..d3d688d --- /dev/null +++ b/src/server.ts @@ -0,0 +1,1178 @@ +#!/usr/bin/env node +import { execFile } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +import type { + ApiTestPlanInput, + CleanupHistoryInput, + CleanupProcessPlanInput, + CommitChecklistInput, + EnvPolicyInput, + ExecResult, + GuardrailSet, + GuardrailsResult, + GuardrailsInput, + InspectAllProjectsInput, + InspectProjectInput, + McpTextResponse, + PackageInfo, + PackageJsonLike, + PageTestCaseInput, + PageTestCaseResult, + PrepareTaskInput, + ProjectAlias, + RepoType, + ResolvedProject, + RiskScanInput, + TaskRecordInput, + TaskType, + VerificationPlanResult, + VerificationPlanInput, + WorkflowContextInput, +} from './types.js'; + +const execFileAsync = promisify(execFile); +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = path.resolve( + process.env.KT_WORKSPACE_ROOT || path.join(dirname, '../../..'), +); + +const projectAliases = { + admin: 'Vue/kt-template-admin', + api: 'Node/kt-template-online-api', + blog: 'Vue/kt-blog-web', + mcp: 'mcp/ktWorkflow', + playground: 'Vue/kt-template-online-playground', + root: '.', + web: 'Vue/kt-template-online-web', +} satisfies Record; + +const projectLabels = { + admin: 'Vue Admin 后台', + api: 'NestJS API 后端', + blog: 'Blog Web 博客前台', + mcp: 'KT Workflow MCP', + playground: 'Playground 编辑器', + root: 'KT 协作根目录', + web: 'Web 前台展示', +} satisfies Record; + +const taskTypeValues = [ + 'api', + 'backend', + 'commit', + 'database', + 'deploy', + 'docs', + 'frontend', + 'general', + 'mcp', + 'page', + 'style', +] as const satisfies readonly TaskType[]; + +const registeredToolNames = [ + 'kt_read_context', + 'kt_inspect_project', + 'kt_inspect_all_projects', + 'kt_guardrails', + 'kt_prepare_task', + 'kt_suggest_verification', + 'kt_create_page_test_case', + 'kt_api_test_plan', + 'kt_cleanup_history', + 'kt_cleanup_process_plan', + 'kt_env_policy', + 'kt_risk_scan', + 'kt_append_task_record', + 'kt_commit_checklist', +] as const; + +const defaultHistoryRoots = [ + 'test-artifacts', + '.codex-test-logs', + 'codex-test-logs', + '.codex-verify', + '.playwright-mcp', + '.codex-db-sync', +] as const; + +function toPosix(value: string): string { + return value.replaceAll(path.sep, '/'); +} + +function resolveInsideRoot(target = '.'): string { + const absoluteTarget = path.isAbsolute(target) + ? path.resolve(target) + : path.resolve(workspaceRoot, target); + const relative = path.relative(workspaceRoot, absoluteTarget); + + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Path is outside KT workspace: ${target}`); + } + + return absoluteTarget; +} + +function resolveProject(project = 'root'): ResolvedProject { + const normalized = (projectAliases as Record)[project] || project; + const absolutePath = resolveInsideRoot(normalized); + const alias = (Object.entries(projectAliases) as Array<[ProjectAlias, string]>).find( + ([, relativePath]) => resolveInsideRoot(relativePath) === absolutePath, + )?.[0]; + + return { + alias: alias || null, + label: alias ? projectLabels[alias] : '自定义路径', + path: absolutePath, + relativePath: toPosix(path.relative(workspaceRoot, absolutePath)) || '.', + }; +} + +function readText(relativePath: string, fallback = ''): string { + const filePath = resolveInsideRoot(relativePath); + if (!existsSync(filePath)) return fallback; + return readFileSync(filePath, 'utf8'); +} + +function readTextIfExists(filePath: string, fallback = ''): string { + if (!existsSync(filePath)) return fallback; + return readFileSync(filePath, 'utf8'); +} + +function readJson>(filePath: string): T | null { + if (!existsSync(filePath)) return null; + + try { + return JSON.parse(readFileSync(filePath, 'utf8')) as T; + } catch { + return null; + } +} + +function listImmediateChildren(targetPath: string): Array<{ name: string; type: 'directory' | 'file' }> { + if (!existsSync(targetPath)) return []; + + return readdirSync(targetPath, { withFileTypes: true }).map((item) => ({ + name: item.name, + type: item.isDirectory() ? 'directory' : 'file', + })); +} + +function getRecentTaskRecords(count = 5): string { + const tasks = readText('TASKS.md'); + const records = tasks + .split(/\n(?=### \d{4}-\d{2}-\d{2}:)/) + .filter((section) => section.startsWith('### ')) + .slice(0, count); + + return records.join('\n\n').trim(); +} + +function getErrorText(error: unknown, key: 'message' | 'stderr' | 'stdout'): string { + const value = (error as Partial>)[key]; + if (typeof value === 'string') return value; + if (Buffer.isBuffer(value)) return value.toString('utf8'); + return ''; +} + +async function tryExecFile(command: string, args: string[], cwd: string): Promise { + try { + const result = await execFileAsync(command, args, { + cwd, + maxBuffer: 1024 * 1024 * 4, + timeout: 20_000, + windowsHide: true, + }); + return { + ok: true, + stderr: result.stderr.trim(), + stdout: result.stdout.trim(), + }; + } catch (error) { + return { + ok: false, + stderr: `${getErrorText(error, 'stderr') || getErrorText(error, 'message')}`.trim(), + stdout: getErrorText(error, 'stdout').trim(), + }; + } +} + +function detectRepoType(projectPath: string): RepoType { + if (existsSync(path.join(projectPath, '.git'))) return 'git'; + if (existsSync(path.join(projectPath, '.svn'))) return 'svn'; + return 'none'; +} + +function detectPackageManager(projectPath: string): PackageInfo { + const packageJsonPath = path.join(projectPath, 'package.json'); + const packageJson = readJson(packageJsonPath); + const lockfiles = { + npm: existsSync(path.join(projectPath, 'package-lock.json')), + pnpm: existsSync(path.join(projectPath, 'pnpm-lock.yaml')), + yarn: existsSync(path.join(projectPath, 'yarn.lock')), + }; + const packageManagerSpec = packageJson?.packageManager || null; + const packageManager = + packageManagerSpec?.split('@')[0] || + (lockfiles.pnpm && 'pnpm') || + (lockfiles.yarn && 'yarn') || + (lockfiles.npm && 'npm') || + null; + + return { + engines: packageJson?.engines || {}, + lockfiles, + packageManager, + packageManagerSpec, + scripts: packageJson?.scripts || {}, + }; +} + +async function inspectProject(input: InspectProjectInput): Promise> { + const project = resolveProject(input.project); + const repoType = detectRepoType(project.path); + const packageInfo = detectPackageManager(project.path); + const nodeVersionFile = ['.node-version', '.nvmrc'].find((file) => + existsSync(path.join(project.path, file)), + ); + const nodeVersion = nodeVersionFile + ? readFileSync(path.join(project.path, nodeVersionFile), 'utf8').trim() + : null; + const envFiles = ['.env.development', '.env.production', '.env.example'].map( + (file) => ({ + exists: existsSync(path.join(project.path, file)), + file, + }), + ); + const childSummary = listImmediateChildren(project.path).slice(0, 60); + const git = + repoType === 'git' && input.includeGit !== false + ? { + branch: await tryExecFile('git', ['branch', '--show-current'], project.path), + remote: await tryExecFile('git', ['remote', '-v'], project.path), + status: await tryExecFile('git', ['status', '--short'], project.path), + } + : null; + + return { + childSummary, + envFiles, + git, + nodeVersion, + nodeVersionFile: nodeVersionFile || null, + packageInfo, + project, + repoType, + }; +} + +function getTypecheckCommand( + project: ResolvedProject, + packageManager: string, + scripts: Record, +): string | null { + if (project.alias === 'admin') { + return 'pnpm -F @vben/web-antdv-next run typecheck'; + } + if (project.alias === 'blog') { + return 'pnpm run type-check'; + } + if (project.alias === 'web') { + return 'pnpm exec vue-tsc --noEmit'; + } + if (scripts.typecheck) return `${packageManager} run typecheck`; + if (scripts['check:type']) return `${packageManager} run check:type`; + if (scripts.check) return `${packageManager} run check`; + return null; +} + +function buildVerificationPlan(input: VerificationPlanInput): VerificationPlanResult { + const project = resolveProject(input.project); + const repoType = detectRepoType(project.path); + const packageInfo = detectPackageManager(project.path); + const packageManager = packageInfo.packageManager || 'pnpm'; + const scripts = packageInfo.scripts; + const changeType = input.changeType || 'general'; + const commands = repoType === 'git' ? ['git status --short'] : []; + const notes = [ + '先确认仓库类型,再确认包管理器和 Node 版本。', + '只跑当前变更所需的最轻验证,不默认全量 build。', + ]; + + const typecheck = getTypecheckCommand(project, packageManager, scripts); + + if (changeType === 'style' && project.alias === 'admin') { + commands.push( + 'pnpm exec stylelint "apps/web-antdv-next/src/components/**/*.{scss,css,vue}"', + ); + } + + if (repoType === 'none') { + notes.push('当前目录不是独立 Git/SVN 仓库,提交和 diff 检查需要切到对应子仓库执行。'); + } + + if ( + typecheck && + ['api', 'frontend', 'general', 'page', 'style'].includes(changeType) + ) { + commands.push(typecheck); + } + + if (project.alias === 'api') { + if (scripts.lint) commands.push(`${packageManager} run lint`); + if (scripts.test && ['api', 'backend', 'general'].includes(changeType)) { + commands.push(`${packageManager} test -- --passWithNoTests`); + } + if (['api', 'backend'].includes(changeType)) { + notes.push('接口改动需要本地启动或复用本地服务,真实调用对应接口一次。'); + } + } + + if (project.alias === 'playground' && scripts['build-preview'] && changeType === 'deploy') { + commands.push(`${packageManager} run build-preview`); + } + + if (project.alias === 'web' && scripts.build && changeType === 'deploy') { + commands.push(`${packageManager} run build`); + } + + if (project.alias === 'mcp') { + if (scripts['self-test']) commands.push(`${packageManager} run self-test`); + notes.push('MCP 变更需要用真实 SDK Client 做 listTools/callTool smoke test。'); + } + + if (input.includePageTest || changeType === 'page') { + notes.push( + '页面级测试必须先写测试用例,使用可见浏览器或截图记录关键步骤,同一用例最多三轮闭环。', + ); + } + + if (repoType === 'git') commands.push('git diff --check'); + + return { + changeType, + commands: Array.from(new Set(commands)), + notes, + project, + repoType, + }; +} + +function createPageTestCase(input: PageTestCaseInput): PageTestCaseResult { + const project = resolveProject(input.project); + const title = input.title || `${project.label} 页面级测试`; + const steps = + (input.steps?.length ?? 0) > 0 + ? input.steps! + : ['打开入口 URL', '完成登录或注入测试登录态', '执行用户关键路径操作', '保存关键步骤截图']; + const assertions = + (input.assertions?.length ?? 0) > 0 + ? input.assertions! + : ['页面无明显渲染错误', '控制台无 error', '关键接口返回成功', '核心 DOM 或业务状态符合预期']; + + return { + account: input.account || '按当前环境使用数据库管理员账号或测试专用账号', + assertions, + cleanup: [ + '关闭浏览器实例', + '清理本次启动的 Node/Vite 进程', + '保存截图和 result.json', + '调用 kt_cleanup_history,仅保留最近 3 轮历史产物', + ], + entryUrl: input.entryUrl || '待填写', + maxRounds: 3, + project, + protocol: [ + '测试前先写用例。', + '失败后先记录复现证据,再排查和修复。', + '按同一用例复测。', + '第三轮仍失败时停止并等待用户建议。', + ], + steps, + title, + }; +} + +async function inspectAllProjects(input: InspectAllProjectsInput = {}): Promise> { + const aliases = input.includeRoot === false + ? Object.keys(projectAliases).filter((key) => key !== 'root') + : Object.keys(projectAliases); + const inspections = await Promise.all( + aliases.map((alias) => + inspectProject({ + includeGit: input.includeGit !== false, + project: alias, + }), + ), + ); + + return { + inspections, + workspaceRoot, + }; +} + +function buildGuardrails(input: GuardrailsInput): GuardrailsResult { + const project = resolveProject(input.project); + const taskType = input.taskType || input.changeType || 'general'; + const paths = input.paths || []; + const guardrails: GuardrailSet = { + beforeEdit: [ + '先读最近的 AGENTS.md,再读 TASKS.md 和 SKILLS.md。', + '先检查目标仓库状态,识别用户已有改动,不覆盖、不回滚无关文件。', + '先快速扫现有代码风格和目录结构,再决定改动方式。', + '搜索优先用 rg,路径和接口名尽量保持可 grep。', + ], + duringEdit: [ + '改动范围收敛到本次需求相关文件。', + '复杂度高的地方加简短注释,避免空泛解释。', + '真实环境配置不写入提交内容,优先只维护 .env.example。', + '前端实现遵循现有 antdv-next/Vben 约定,不引入无关抽象。', + ], + doNot: [ + '不要执行 git reset --hard、git checkout -- 等破坏性回滚。', + '不要在用户未要求时自动 push。', + '不要把真实密钥、数据库密码、生产 env 写进可提交文件。', + '不要为了验证长期保留 Node/Vite/浏览器进程。', + ], + verification: buildVerificationPlan({ + changeType: taskType, + includePageTest: input.includePageTest || taskType === 'page', + project: input.project, + }), + }; + + if (['api', 'backend'].includes(taskType)) { + guardrails.beforeEdit.push('接口改动先确认 DTO、Controller、Service、返回结构和鉴权链路。'); + guardrails.duringEdit.push('接口成功返回统一保持 code=200/msg/data,错误返回 err 字段。'); + guardrails.verification.notes.push('接口改完必须本地真实调用一次对应接口。'); + } + + if (taskType === 'page' || input.includePageTest) { + guardrails.beforeEdit.push('页面级测试先写用例,再打开可视化浏览器执行。'); + guardrails.verification.pageTestCase = createPageTestCase({ + project: input.project, + title: input.userRequest || `${project.label} 页面级测试`, + }); + } + + if (taskType === 'database') { + guardrails.beforeEdit.push('数据库同步或修复前先确认源库、目标库、备份库和回滚点。'); + guardrails.doNot.push('不要在未确认目标库前执行覆盖、drop、truncate。'); + } + + if (taskType === 'deploy') { + guardrails.duringEdit.push('部署配置保留真正能提升运行稳定性的内容,删掉噪音配置。'); + guardrails.doNot.push('不要在未要求时触发真实部署或改动远程服务。'); + } + + if (taskType === 'mcp') { + guardrails.duringEdit.push('MCP 工具描述要明确边界,默认输出建议和检查清单,不做高风险副作用。'); + guardrails.verification.notes.push('MCP 变更至少跑 self-test 和真实 SDK Client smoke test。'); + } + + return { + guardrails, + paths, + project, + taskType, + userRequest: input.userRequest || '', + }; +} + +function createApiTestPlan(input: ApiTestPlanInput): Record { + const project = resolveProject(input.project || 'api'); + const method = (input.method || 'GET').toUpperCase(); + const endpoint = input.endpoint || '/api/待填写'; + const baseUrl = input.baseUrl || 'http://localhost:3000'; + const headers = []; + + if (input.auth === 'bearer') { + headers.push('-H "Authorization: Bearer "'); + } + + if (input.contentType !== false && ['POST', 'PUT', 'PATCH'].includes(method)) { + headers.push('-H "Content-Type: application/json"'); + } + + const body = + input.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) + ? ` -d '${JSON.stringify(input.body)}'` + : ''; + const curl = `curl -i -X ${method} ${headers.join(' ')} "${baseUrl}${endpoint}"${body}`; + + return { + assertions: [ + 'HTTP 状态码符合预期。', + '成功响应结构为 { code: 200, msg: string, data: any }。', + '成功响应不返回 err 字段。', + ...(input.expectedFields || []).map((field) => `data 中包含 ${field}`), + ], + curl, + endpoint, + method, + notes: [ + '接口改动后不要只跑类型检查,必须真实调用对应接口一次。', + '如依赖登录态,先调用 login 获取 token,再带 Authorization 复测。', + ], + project, + }; +} + +function createCleanupProcessPlan(input: CleanupProcessPlanInput): Record { + const project = resolveProject(input.project); + const escapedProjectPath = project.path.replaceAll('\\', '\\\\'); + const portFilters = (input.ports || []).map((port) => ({ + command: `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`, + port, + })); + + return { + commands: [ + `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escapedProjectPath}*' -and $_.Name -match 'node|pnpm|npm|yarn' } | Select-Object ProcessId,Name,CommandLine`, + '确认命中进程只属于本次验证后,再用 Stop-Process -Id 结束。', + ...portFilters.map((item) => item.command), + ], + notes: [ + '先按项目路径筛选,再按端口交叉确认。', + '不要批量结束与本次验证无关的 Node 进程。', + '页面测试结束后清理浏览器和 Vite/Nest 进程,避免内存累积。', + ], + project, + }; +} + +interface HistoryEntry { + lastWriteTime: string; + mtimeMs: number; + name: string; + path: string; + relativePath: string; + type: 'directory' | 'file'; +} + +function listHistoryEntries(rootPath: string, preservedNames: Set): HistoryEntry[] { + if (!existsSync(rootPath)) return []; + + return readdirSync(rootPath, { withFileTypes: true }) + .filter((item) => !preservedNames.has(item.name)) + .map((item) => { + const entryPath = path.join(rootPath, item.name); + const stats = statSync(entryPath); + + return { + lastWriteTime: stats.mtime.toISOString(), + mtimeMs: stats.mtimeMs, + name: item.name, + path: entryPath, + relativePath: toPosix(path.relative(workspaceRoot, entryPath)), + type: item.isDirectory() ? ('directory' as const) : ('file' as const), + }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); +} + +function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record { + const keep = Math.max(0, Math.min(input.keep ?? 3, 50)); + const dryRun = input.dryRun ?? true; + const roots = (input.roots?.length ?? 0) > 0 ? input.roots! : [...defaultHistoryRoots]; + const result = { + deleted: [] as Array>, + dryRun, + keep, + preserved: [] as Array>, + roots: [] as Array<{ + candidates: number; + deleteCount: number; + keepCount: number; + preservedNames: string[]; + root: string; + }>, + skipped: [] as Array<{ + reason: string; + root: string; + }>, + }; + + for (const root of roots) { + const rootPath = resolveInsideRoot(root); + const rootRelativePath = toPosix(path.relative(workspaceRoot, rootPath)) || '.'; + + if (!existsSync(rootPath)) { + result.skipped.push({ + reason: 'not_exists', + root: rootRelativePath, + }); + continue; + } + + const preservedNames = new Set(rootRelativePath === 'test-artifacts' ? ['_templates'] : []); + const entries = listHistoryEntries(rootPath, preservedNames); + const preserved = entries.slice(0, keep); + const deleting = entries.slice(keep); + + result.roots.push({ + candidates: entries.length, + deleteCount: deleting.length, + keepCount: preserved.length, + preservedNames: Array.from(preservedNames), + root: rootRelativePath, + }); + result.preserved.push(...preserved.map(({ mtimeMs, path: _path, ...entry }) => entry)); + + for (const entry of deleting) { + if (!dryRun) { + rmSync(entry.path, { + force: true, + recursive: true, + }); + } + const { mtimeMs, path: _path, ...publicEntry } = entry; + result.deleted.push(publicEntry); + } + } + + return result; +} + +function parseCliCleanupArgs(args: string[]): CleanupHistoryInput { + const keepArg = args.find((arg) => arg.startsWith('--keep=')); + const rootsArg = args.find((arg) => arg.startsWith('--roots=')); + + return { + dryRun: args.includes('--dry-run'), + keep: keepArg ? Number.parseInt(keepArg.split('=')[1], 10) : 3, + roots: rootsArg ? rootsArg.split('=')[1].split(',').filter(Boolean) : [...defaultHistoryRoots], + }; +} + +function buildEnvPolicy(input: EnvPolicyInput): Record { + const project = resolveProject(input.project); + const envFiles = ['.env.development', '.env.production', '.env.example'].map((file) => { + const absolutePath = path.join(project.path, file); + return { + exists: existsSync(absolutePath), + file, + relativePath: toPosix(path.relative(workspaceRoot, absolutePath)), + }; + }); + const gitignore = readTextIfExists(path.join(project.path, '.gitignore')); + const ignoredRules = gitignore + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + return { + envFiles, + ignoredRules: ignoredRules.filter((line) => line.includes('.env')), + policy: [ + '真实开发和生产配置只保留在本地,不提交。', + '.env.example 用于维护变量名和示例值。', + '接口、数据库、WordPress、MinIO 等真实账号密码不得写入文档或提交记录。', + ], + project, + warnings: envFiles + .filter((file) => file.file !== '.env.example' && file.exists) + .map((file) => `${file.file} 存在,提交前确认已被 .gitignore 忽略。`), + }; +} + +async function scanTaskRisk(input: RiskScanInput): Promise> { + const project = resolveProject(input.project); + let changedFiles = input.changedFiles || []; + + if (changedFiles.length === 0 && detectRepoType(project.path) === 'git') { + const status = await tryExecFile('git', ['status', '--short'], project.path); + if (status.ok) { + changedFiles = status.stdout + .split(/\r?\n/) + .map((line) => line.slice(3).trim()) + .filter(Boolean); + } + } + + const risks: Array<{ + file: string; + level: 'high' | 'medium'; + reason: string; + suggestion: string; + }> = []; + const addRisk = ( + level: 'high' | 'medium', + file: string, + reason: string, + suggestion: string, + ) => { + risks.push({ file, level, reason, suggestion }); + }; + + for (const file of changedFiles) { + const normalized = file.replaceAll('\\', '/'); + + if (/\.env\.(development|production)$/.test(normalized)) { + addRisk('high', file, '真实环境配置文件有改动。', '确认不会提交真实密钥和密码。'); + } + + if (/package\.json$|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$/.test(normalized)) { + addRisk('medium', file, '依赖或脚本发生变化。', '确认锁文件与 package.json 同步,并运行对应轻量验证。'); + } + + if (normalized.includes('/components/ktTable/')) { + addRisk( + 'medium', + file, + 'KtTable 是高频核心组件,布局和滚动性能容易回归。', + '保持原生 antdv-next 表格能力,避免监听 scroll/resize 做高频状态更新。', + ); + } + + if (/src\/admin\/|src\/.*controller|src\/.*service|src\/.*dto/.test(normalized)) { + addRisk('medium', file, '后端接口或业务逻辑改动。', '本地启动或复用服务后真实调用对应接口。'); + } + + if (/Jenkinsfile|deploy\/|k8s\/|nginx.*\.conf/.test(normalized)) { + addRisk('medium', file, '部署链路配置改动。', '只验证配置语法或生成产物,不主动触发远程部署。'); + } + } + + return { + changedFiles, + project, + risks, + safeDefaults: [ + '不覆盖用户已有改动。', + '只运行必要验证。', + '提交前重新检查 status 和 diff。', + ], + }; +} + +async function prepareTask(input: PrepareTaskInput): Promise> { + const project = resolveProject(input.project); + const taskType = input.taskType || 'general'; + const inspection = await inspectProject({ + includeGit: input.includeGit !== false, + project: input.project, + }); + const guardrails = buildGuardrails(input); + const riskScan = await scanTaskRisk({ + changedFiles: input.changedFiles || [], + project: input.project, + }); + const pageTestCase = + input.includePageTest || taskType === 'page' + ? createPageTestCase({ + entryUrl: input.entryUrl, + project: input.project, + title: input.userRequest || `${project.label} 页面级测试`, + }) + : null; + + return { + guardrails, + inspection, + nextSteps: [ + '阅读 workPacket.readFirst 中的上下文文件。', + '按 guardrails.beforeEdit 做开工前检查。', + '按最小范围修改。', + '按 verification.commands 和 notes 做轻量验证。', + '有文件改动时更新 TASKS.md。', + ], + pageTestCase, + project, + readFirst: [ + 'AGENTS.md', + 'SKILLS.md', + 'TASKS.md', + `${project.relativePath}/AGENTS.md`, + `${project.relativePath}/package.json`, + ], + riskScan, + taskRecordDraft: buildTaskRecord({ + content: input.userRequest || '待补充', + scope: [project.relativePath], + testCase: pageTestCase ? pageTestCase.title : '本次无页面测试或待补充。', + title: input.recordTitle || input.userRequest || `${project.label} 任务记录`, + verification: guardrails.guardrails.verification.commands.join(';') || '待补充。', + }), + taskType, + userRequest: input.userRequest || '', + }; +} + +function formatDateInShanghai(date = new Date()): string { + const parts = new Intl.DateTimeFormat('en-CA', { + day: '2-digit', + month: '2-digit', + timeZone: 'Asia/Shanghai', + year: 'numeric', + }).formatToParts(date); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}`; +} + +function buildTaskRecord(input: TaskRecordInput): string { + const date = input.date || formatDateInShanghai(); + const scope = (input.scope?.length ?? 0) > 0 ? input.scope!.join('、') : '待补充'; + const content = input.content || '待补充'; + const testCase = input.testCase || '本次无页面测试或待补充。'; + const verification = input.verification || '待补充。'; + + return `### ${date}:${input.title}\n\n- 范围:${scope}\n- 内容:${content}\n- 页面测试用例:${testCase}\n- 验证:${verification}\n`; +} + +function appendTaskRecord(input: TaskRecordInput): Record { + const record = buildTaskRecord(input); + const tasksPath = resolveInsideRoot('TASKS.md'); + const current = readFileSync(tasksPath, 'utf8'); + const marker = '## 改动记录'; + const markerIndex = current.indexOf(marker); + + if (markerIndex === -1) { + throw new Error('TASKS.md does not contain ## 改动记录'); + } + + const insertAt = current.indexOf('\n', markerIndex); + const next = `${current.slice(0, insertAt + 1)}\n${record}\n${current.slice(insertAt + 1)}`; + + if (!input.dryRun) { + writeFileSync(tasksPath, next, 'utf8'); + } + + return { + dryRun: input.dryRun, + record, + target: tasksPath, + }; +} + +async function createCommitChecklist(input: CommitChecklistInput): Promise> { + const inspection = await inspectProject({ + includeGit: true, + project: input.project, + }); + const message = input.message || ''; + const messageOk = + !message || + /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?: .*[一-龥]/u.test( + message, + ); + + return { + checks: [ + '确认只包含本次任务相关文件。', + '先运行对应轻量验证命令。', + '每个独立 Git 仓库单独 commit。', + '没有用户明确要求时不 push。', + ], + inspection, + message, + messageOk, + messageRule: '英文 type 前缀 + 中文描述,例如 feat: 功能性提交', + }; +} + +function readWorkflowContext(input: WorkflowContextInput): Record { + const projects = Object.keys(projectAliases).map((key) => { + const project = resolveProject(key); + return { + ...project, + exists: existsSync(project.path), + repoType: existsSync(project.path) ? detectRepoType(project.path) : 'missing', + }; + }); + + return { + docs: { + agents: readText('AGENTS.md'), + recentTasks: getRecentTaskRecords(input.taskRecordCount), + skills: readText('SKILLS.md'), + }, + projects, + workspaceRoot, + }; +} + +function response(data: unknown): McpTextResponse { + return { + content: [ + { + text: typeof data === 'string' ? data : JSON.stringify(data, null, 2), + type: 'text', + }, + ], + }; +} + +async function runSelfTest(): Promise { + const data = { + context: readWorkflowContext({ taskRecordCount: 2 }), + guardrails: buildGuardrails({ + project: 'mcp', + taskType: 'mcp', + userRequest: '扩展 KT 工作区 MCP 可复用能力', + }), + historyCleanup: cleanupHistoryArtifacts({ + dryRun: true, + keep: 3, + }), + inspectAdmin: await inspectProject({ includeGit: false, project: 'admin' }), + prepareTask: await prepareTask({ + includeGit: false, + project: 'mcp', + taskType: 'mcp', + userRequest: '扩展 KT 工作区 MCP 可复用能力', + }), + verification: buildVerificationPlan({ + changeType: 'mcp', + project: 'mcp', + }), + }; + const outputDir = resolveInsideRoot('.codex-verify/ktWorkflow'); + mkdirSync(outputDir, { recursive: true }); + writeFileSync( + path.join(outputDir, 'self-test.json'), + JSON.stringify(data, null, 2), + 'utf8', + ); + console.log(JSON.stringify({ ok: true, outputDir, tools: registeredToolNames.length }, null, 2)); +} + +function registerTools(server: McpServer): void { + server.registerTool( + 'kt_read_context', + { + description: '读取 KT 根目录 AGENTS/SKILLS/TASKS,并返回项目清单和最近任务记录。', + inputSchema: { + taskRecordCount: z.number().int().min(0).max(20).default(5), + }, + title: 'Read KT Workflow Context', + }, + async (input) => response(readWorkflowContext(input)), + ); + + server.registerTool( + 'kt_inspect_project', + { + description: '检查 KT 子项目的仓库类型、包管理器、Node 版本、env 文件和 Git 状态。', + inputSchema: { + includeGit: z.boolean().default(true), + project: z.string().default('root'), + }, + title: 'Inspect KT Project', + }, + async (input) => response(await inspectProject(input)), + ); + + server.registerTool( + 'kt_inspect_all_projects', + { + description: '一次性检查 KT 工作区所有已知项目,适合多仓库联动任务开工前使用。', + inputSchema: { + includeGit: z.boolean().default(true), + includeRoot: z.boolean().default(true), + }, + title: 'Inspect All KT Projects', + }, + async (input) => response(await inspectAllProjects(input)), + ); + + server.registerTool( + 'kt_guardrails', + { + description: '按任务类型生成 KT 防偏差清单,包含开工、改动、禁止项和验证约束。', + inputSchema: { + changeType: z.enum(taskTypeValues).optional(), + includePageTest: z.boolean().default(false), + paths: z.array(z.string()).default([]), + project: z.string().default('root'), + taskType: z.enum(taskTypeValues).default('general'), + userRequest: z.string().optional(), + }, + title: 'KT Guardrails', + }, + async (input) => response(buildGuardrails(input)), + ); + + server.registerTool( + 'kt_prepare_task', + { + description: '生成一份 KT 任务 work packet:上下文、项目检查、防偏差清单、风险扫描和验证计划。', + inputSchema: { + changedFiles: z.array(z.string()).default([]), + entryUrl: z.string().optional(), + includeGit: z.boolean().default(true), + includePageTest: z.boolean().default(false), + paths: z.array(z.string()).default([]), + project: z.string().default('root'), + recordTitle: z.string().optional(), + taskType: z.enum(taskTypeValues).default('general'), + userRequest: z.string().optional(), + }, + title: 'Prepare KT Task', + }, + async (input) => response(await prepareTask(input)), + ); + + server.registerTool( + 'kt_suggest_verification', + { + description: '按 KT 规则为指定项目和变更类型生成轻量验证命令与注意事项。', + inputSchema: { + changeType: z.enum(taskTypeValues).default('general'), + includePageTest: z.boolean().default(false), + project: z.string().default('root'), + }, + title: 'Suggest KT Verification', + }, + async (input) => response(buildVerificationPlan(input)), + ); + + server.registerTool( + 'kt_create_page_test_case', + { + description: '生成 KT 页面级可视化测试用例骨架,包含事不过三闭环规则。', + inputSchema: { + account: z.string().optional(), + assertions: z.array(z.string()).default([]), + entryUrl: z.string().optional(), + project: z.string().default('admin'), + steps: z.array(z.string()).default([]), + title: z.string().optional(), + }, + title: 'Create KT Page Test Case', + }, + async (input) => response(createPageTestCase(input)), + ); + + server.registerTool( + 'kt_api_test_plan', + { + description: '为后端接口改动生成真实调用测试计划和响应结构断言。', + inputSchema: { + auth: z.enum(['none', 'bearer']).default('bearer'), + baseUrl: z.string().default('http://localhost:3000'), + body: z.record(z.any()).optional(), + contentType: z.boolean().default(true), + endpoint: z.string().default('/api/待填写'), + expectedFields: z.array(z.string()).default([]), + method: z.enum(['DELETE', 'GET', 'PATCH', 'POST', 'PUT']).default('GET'), + project: z.string().default('api'), + }, + title: 'KT API Test Plan', + }, + async (input) => response(createApiTestPlan(input)), + ); + + server.registerTool( + 'kt_cleanup_history', + { + description: '清理 KT 根目录历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个历史目录仅保留最近 3 轮,test-artifacts/_templates 永久保留。', + inputSchema: { + dryRun: z.boolean().default(true), + keep: z.number().int().min(0).max(50).default(3), + roots: z.array(z.string()).default([...defaultHistoryRoots]), + }, + title: 'KT Cleanup History Artifacts', + }, + async (input) => response(cleanupHistoryArtifacts(input)), + ); + + server.registerTool( + 'kt_cleanup_process_plan', + { + description: '生成按项目路径和端口清理验证进程的 PowerShell 检查命令,不直接杀进程。', + inputSchema: { + ports: z.array(z.number().int().min(1).max(65_535)).default([]), + project: z.string().default('root'), + }, + title: 'KT Cleanup Process Plan', + }, + async (input) => response(createCleanupProcessPlan(input)), + ); + + server.registerTool( + 'kt_env_policy', + { + description: '检查指定项目 env 文件现状,并输出 KT 环境配置提交策略。', + inputSchema: { + project: z.string().default('root'), + }, + title: 'KT Env Policy', + }, + async (input) => response(buildEnvPolicy(input)), + ); + + server.registerTool( + 'kt_risk_scan', + { + description: '按当前变更文件或传入文件列表扫描常见偏差风险和验证提醒。', + inputSchema: { + changedFiles: z.array(z.string()).default([]), + project: z.string().default('root'), + }, + title: 'KT Risk Scan', + }, + async (input) => response(await scanTaskRisk(input)), + ); + + server.registerTool( + 'kt_append_task_record', + { + description: '生成或写入 TASKS.md 改动记录。默认 dryRun=true,只预览不落盘。', + inputSchema: { + content: z.string().optional(), + date: z.string().optional(), + dryRun: z.boolean().default(true), + scope: z.array(z.string()).default([]), + testCase: z.string().optional(), + title: z.string(), + verification: z.string().optional(), + }, + title: 'Append KT Task Record', + }, + async (input) => response(appendTaskRecord(input)), + ); + + server.registerTool( + 'kt_commit_checklist', + { + description: '生成 KT 子仓库提交前检查清单,并校验 commit message 是否符合约定。', + inputSchema: { + message: z.string().optional(), + project: z.string().default('root'), + }, + title: 'KT Commit Checklist', + }, + async (input) => response(await createCommitChecklist(input)), + ); +} + +if (process.argv.includes('--cleanup-history')) { + console.log(JSON.stringify(cleanupHistoryArtifacts(parseCliCleanupArgs(process.argv)), null, 2)); +} else if (process.argv.includes('--self-test')) { + await runSelfTest(); +} else { + const server = new McpServer({ + name: 'kt-workflow', + version: '0.2.0', + }); + registerTools(server); + await server.connect(new StdioServerTransport()); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2b534a2 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,186 @@ +export type ProjectAlias = + | 'admin' + | 'api' + | 'blog' + | 'mcp' + | 'playground' + | 'root' + | 'web'; + +export type RepoType = 'git' | 'missing' | 'none' | 'svn'; + +export type TaskType = + | 'api' + | 'backend' + | 'commit' + | 'database' + | 'deploy' + | 'docs' + | 'frontend' + | 'general' + | 'mcp' + | 'page' + | 'style'; + +export interface ResolvedProject { + alias: ProjectAlias | null; + label: string; + path: string; + relativePath: string; +} + +export interface ExecResult { + ok: boolean; + stderr: string; + stdout: string; +} + +export interface PackageJsonLike { + engines?: Record; + packageManager?: string; + scripts?: Record; +} + +export interface PackageInfo { + engines: Record; + lockfiles: Record<'npm' | 'pnpm' | 'yarn', boolean>; + packageManager: string | null; + packageManagerSpec: string | null; + scripts: Record; +} + +export interface InspectProjectInput { + includeGit?: boolean; + project?: string; +} + +export interface InspectAllProjectsInput { + includeGit?: boolean; + includeRoot?: boolean; +} + +export interface VerificationPlanInput { + changeType?: TaskType; + includePageTest?: boolean; + project?: string; +} + +export interface PageTestCaseInput { + account?: string; + assertions?: string[]; + entryUrl?: string; + project?: string; + steps?: string[]; + title?: string; +} + +export interface PageTestCaseResult { + account: string; + assertions: string[]; + cleanup: string[]; + entryUrl: string; + maxRounds: number; + project: ResolvedProject; + protocol: string[]; + steps: string[]; + title: string; +} + +export interface GuardrailsInput { + changeType?: TaskType; + includePageTest?: boolean; + paths?: string[]; + project?: string; + taskType?: TaskType; + userRequest?: string; +} + +export interface ApiTestPlanInput { + auth?: 'bearer' | 'none'; + baseUrl?: string; + body?: Record; + contentType?: boolean; + endpoint?: string; + expectedFields?: string[]; + method?: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; + project?: string; +} + +export interface CleanupProcessPlanInput { + ports?: number[]; + project?: string; +} + +export interface CleanupHistoryInput { + dryRun?: boolean; + keep?: number; + roots?: string[]; +} + +export interface EnvPolicyInput { + project?: string; +} + +export interface RiskScanInput { + changedFiles?: string[]; + project?: string; +} + +export interface PrepareTaskInput extends GuardrailsInput { + changedFiles?: string[]; + entryUrl?: string; + includeGit?: boolean; + recordTitle?: string; +} + +export interface TaskRecordInput { + content?: string; + date?: string; + dryRun?: boolean; + scope?: string[]; + testCase?: string; + title: string; + verification?: string; +} + +export interface CommitChecklistInput { + message?: string; + project?: string; +} + +export interface WorkflowContextInput { + taskRecordCount?: number; +} + +export interface VerificationPlanResult { + changeType: TaskType; + commands: string[]; + notes: string[]; + project: ResolvedProject; + repoType: RepoType; +} + +export interface GuardrailSet { + beforeEdit: string[]; + doNot: string[]; + duringEdit: string[]; + verification: VerificationPlanResult & { + pageTestCase?: PageTestCaseResult; + }; +} + +export interface GuardrailsResult { + guardrails: GuardrailSet; + paths: string[]; + project: ResolvedProject; + taskType: TaskType; + userRequest: string; +} + +export interface McpTextResponse { + [key: string]: unknown; + content: Array<{ + text: string; + type: 'text'; + }>; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cf5f9db --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}