refactor: 拆分ktworkflow模块

This commit is contained in:
sunlei 2026-06-02 07:23:58 +08:00
parent 84309e9c9a
commit 13fc24063b
19 changed files with 2238 additions and 1164 deletions

View File

@ -8,11 +8,18 @@
- 检查子项目环境Git/SVN、包管理器、Node 版本、env 文件、Git 状态。
- 生成防偏差工作包:把开工前检查、禁止项、风险扫描、验证计划汇总成一份可执行清单。
- 生成验证建议按后端、前端、样式、页面、部署、MCP 等变更类型给出轻量验证命令。
- 固化改动后 review验证计划和提交清单都会提醒执行 `kt_global_code_review` / `pnpm run global-review`
- 生成任务收尾包:把状态、验证计划、可选验证执行、全局 review 和历史清理收成一份结果。
- 生成页面测试用例:内置“先写用例、可视化证据、事不过三”的测试闭环。
- 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。
- 生成业务链路测试计划:固化 Admin 登录、博客 CRUD、QQBot 扫码/自动回复、Web/Playground 回跳。
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、备份和行数校验。
- 生成专项组件工作流KtTable、BlogArgon、AdminAuth、QQBot 的防踩坑清单和验证点。
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
- 清理历史产物:按目录最近修改时间只保留最近 3 轮测试/验证产物,模板目录永久保留。
- 检查 env 策略和变更风险:提醒真实配置、锁文件、核心表格组件、部署链路等高风险改动。
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量和当前变更风险;任何文件改动后都要跑一遍。
- 生成或写入 `TASKS.md` 改动记录:默认 `dryRun=true`,确认后再落盘。
- 生成提交前检查清单:校验 KT commit message 约定。
@ -67,6 +74,14 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
| `kt_cleanup_process_plan` | 生成验证进程清理检查命令 |
| `kt_env_policy` | 检查 env 文件现状和提交策略 |
| `kt_risk_scan` | 扫描当前或传入变更文件的偏差风险 |
| `kt_global_code_review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描 |
| `kt_finish_task` | 生成任务收尾包,可选执行验证和历史清理 |
| `kt_commit_plan` | 生成多仓库提交计划、建议 commit message 和检查项 |
| `kt_push_plan` | 生成多仓库推送计划和远程异常提醒 |
| `kt_business_test_plan` | 生成固化业务链路测试计划 |
| `kt_remote_health_check` | 生成或执行远程只读健康检查命令 |
| `kt_db_sync_plan` | 生成数据库同步安全向导 |
| `kt_component_workflow` | 输出专项组件/链路防踩坑工作流 |
| `kt_append_task_record` | 预览或写入 `TASKS.md` 改动记录 |
| `kt_commit_checklist` | 生成提交前检查清单并校验 commit message |
@ -75,6 +90,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
| 脚本 | 用途 |
| --- | --- |
| `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 |
| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,输出 JSON 复审报告。 |
## 项目别名
@ -90,7 +106,10 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
## 开发说明
- 主入口是 `src/server.ts`,工具入参类型集中在 `src/types.ts`
- 主入口是 `src/server.ts`,只负责 CLI 分支和 MCP Server 汇聚启动。
- 工具入参类型集中在 `src/types.ts`MCP 注册集中在 `src/registerTools.ts`
- `src/core/*` 放项目别名、工作区路径、命令执行、仓库/包管理器识别等基础能力。
- `src/tools/*` 按功能拆分:检查、验证、测试、清理、风险、复审、任务记录和工作流计划。
- MCP 客户端直接通过 `node --import tsx loader` 启动 TS 入口,不再保留旧的 `src/server.mjs`
- Node/npm/pnpm 版本异常时优先走 `nvm ls` + `nvm use`,不要用扫盘路径作为第一选择。
@ -98,6 +117,13 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
- 写代码前先调用 `kt_prepare_task`;只需要单项信息时再调用 `kt_read_context``kt_inspect_project`
- 多项目联动时先调用 `kt_inspect_all_projects`
- 要收尾时调用 `kt_finish_task`,默认只生成计划和 review需要执行验证时显式传 `runValidation=true`
- 文件改动完成并验证后调用 `kt_global_code_review`,或运行 `pnpm run global-review`;它只读扫描,不删除文件、不提交代码。
- 要提交或推送时先调用 `kt_commit_plan` / `kt_push_plan`,按仓库分组确认范围。
- 要测真实业务链路时调用 `kt_business_test_plan`,选择 `admin-login`、`qqbot-auto-reply` 等 flow。
- 远程服务排查先调用 `kt_remote_health_check`,默认只生成只读命令;需要执行时显式传 `execute=true`
- 数据库同步前调用 `kt_db_sync_plan`,先确认源库、目标库、备份库和校验点。
- 改 KtTable、BlogArgon、AdminAuth、QQBot 前调用 `kt_component_workflow`,先看专项禁区。
- 不确定任务边界时调用 `kt_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。
- 验证前调用 `kt_suggest_verification`,避免盲跑全量构建。
- 页面测试前调用 `kt_create_page_test_case`,再执行 Playwright/浏览器测试。

View File

@ -1,12 +1,13 @@
{
"name": "@kt/mcp-kt-workflow",
"version": "0.2.0",
"version": "0.4.0",
"private": true,
"type": "module",
"description": "Reusable MCP workflow tools for the KT workspace.",
"scripts": {
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
"cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history",
"global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review",
"self-test": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --self-test",
"start": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts",
"typecheck": "tsc --noEmit"

68
src/core/constants.ts Normal file
View File

@ -0,0 +1,68 @@
import type { ProjectAlias, TaskType } from '../types.js';
export 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<ProjectAlias, string>;
export const projectLabels = {
admin: 'Vue Admin 后台',
api: 'NestJS API 后端',
blog: 'Blog Web 博客前台',
mcp: 'KT Workflow MCP',
playground: 'Playground 编辑器',
root: 'KT 协作根目录',
web: 'Web 前台展示',
} satisfies Record<ProjectAlias, string>;
export const taskTypeValues = [
'api',
'backend',
'commit',
'database',
'deploy',
'docs',
'frontend',
'general',
'mcp',
'page',
'style',
] as const satisfies readonly TaskType[];
export 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_global_code_review',
'kt_finish_task',
'kt_commit_plan',
'kt_push_plan',
'kt_business_test_plan',
'kt_remote_health_check',
'kt_db_sync_plan',
'kt_component_workflow',
'kt_append_task_record',
'kt_commit_checklist',
] as const;
export const defaultHistoryRoots = [
'test-artifacts',
'.codex-test-logs',
'codex-test-logs',
'.codex-verify',
'.playwright-mcp',
'.codex-db-sync',
] as const;

43
src/core/exec.ts Normal file
View File

@ -0,0 +1,43 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { ExecResult } from '../types.js';
const execFileAsync = promisify(execFile);
function getErrorText(error: unknown, key: 'message' | 'stderr' | 'stdout'): string {
const value = (error as Partial<Record<typeof key, unknown>>)[key];
if (typeof value === 'string') return value;
if (Buffer.isBuffer(value)) return value.toString('utf8');
return '';
}
export async function tryExecFile(
command: string,
args: string[],
cwd: string,
timeoutMs = 20_000,
): Promise<ExecResult> {
try {
const result = await execFileAsync(command, args, {
cwd,
maxBuffer: 1024 * 1024 * 4,
timeout: timeoutMs,
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(),
};
}
}
export async function tryPowerShell(command: string, cwd: string, timeoutMs = 120_000): Promise<ExecResult> {
return tryExecFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', command], cwd, timeoutMs);
}

55
src/core/project.ts Normal file
View File

@ -0,0 +1,55 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { PackageInfo, PackageJsonLike, RepoType, ResolvedProject } from '../types.js';
import { readJson } from './workspace.js';
export function detectRepoType(projectPath: string): RepoType {
if (existsSync(path.join(projectPath, '.git'))) return 'git';
if (existsSync(path.join(projectPath, '.svn'))) return 'svn';
return 'none';
}
export function detectPackageManager(projectPath: string): PackageInfo {
const packageJsonPath = path.join(projectPath, 'package.json');
const packageJson = readJson<PackageJsonLike>(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 || {},
};
}
export function getTypecheckCommand(
project: ResolvedProject,
packageManager: string,
scripts: Record<string, string>,
): 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;
}

97
src/core/workspace.ts Normal file
View File

@ -0,0 +1,97 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ProjectAlias, ResolvedProject } from '../types.js';
import { projectAliases, projectLabels } from './constants.js';
const dirname = path.dirname(fileURLToPath(import.meta.url));
export const workspaceRoot = path.resolve(
process.env.KT_WORKSPACE_ROOT || path.join(dirname, '../../../..'),
);
export function toPosix(value: string): string {
return value.replaceAll(path.sep, '/');
}
export 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;
}
export function getGlobalReviewCommand(): string {
return `pnpm --dir ${toPosix(resolveInsideRoot(projectAliases.mcp))} run global-review`;
}
export function resolveProject(project = 'root'): ResolvedProject {
const normalized = (projectAliases as Record<string, string>)[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)) || '.',
};
}
export function readText(relativePath: string, fallback = ''): string {
const filePath = resolveInsideRoot(relativePath);
if (!existsSync(filePath)) return fallback;
return readFileSync(filePath, 'utf8');
}
export function readTextIfExists(filePath: string, fallback = ''): string {
if (!existsSync(filePath)) return fallback;
return readFileSync(filePath, 'utf8');
}
export function readJson<T = Record<string, unknown>>(filePath: string): T | null {
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
} catch {
return null;
}
}
export 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',
}));
}
export 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();
}
export 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}`;
}

360
src/registerTools.ts Normal file
View File

@ -0,0 +1,360 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { McpTextResponse } from './types.js';
import { defaultHistoryRoots, taskTypeValues } from './core/constants.js';
import { createCleanupProcessPlan, cleanupHistoryArtifacts } from './tools/cleanup.js';
import { buildEnvPolicy, scanTaskRisk } from './tools/envRisk.js';
import { buildGuardrails } from './tools/guardrails.js';
import { inspectAllProjects, inspectProject } from './tools/inspect.js';
import { buildGlobalCodeReview, defaultReviewProjects } from './tools/review.js';
import { appendTaskRecord, createCommitChecklist, prepareTask, readWorkflowContext } from './tools/task.js';
import { buildBusinessTestPlan, createApiTestPlan, createPageTestCase } from './tools/testing.js';
import { buildVerificationPlan } from './tools/verification.js';
import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js';
export function response(data: unknown): McpTextResponse {
return {
content: [
{
text: typeof data === 'string' ? data : JSON.stringify(data, null, 2),
type: 'text',
},
],
};
}
export 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_global_code_review',
{
description: '对 KT 已知子仓库做只读全局 CodeReview 扫描Git 状态、tracked env/deploy/secrets、冲突标记、调试输出、疑似凭据字面量和当前变更风险。',
inputSchema: {
includeContentScan: z.boolean().default(true),
includeRootScan: z.boolean().default(true),
maxFindingsPerProject: z.number().int().min(1).max(200).default(20),
projects: z.array(z.string()).default([...defaultReviewProjects]),
},
title: 'KT Global Code Review',
},
async (input) => response(await buildGlobalCodeReview(input)),
);
server.registerTool(
'kt_finish_task',
{
description: '一键生成任务收尾包:项目状态、验证计划、可选验证执行、全局 review、历史产物清理预览/执行和停止条件。',
inputSchema: {
changeType: z.enum(taskTypeValues).default('general'),
cleanupHistory: z.boolean().default(false),
includeRootScan: z.boolean().default(true),
keepHistory: z.number().int().min(0).max(50).default(3),
maxFindingsPerProject: z.number().int().min(1).max(200).default(20),
projects: z.array(z.string()).default([...defaultReviewProjects]),
runValidation: z.boolean().default(false),
},
title: 'KT Finish Task',
},
async (input) => response(await buildFinishTask(input)),
);
server.registerTool(
'kt_commit_plan',
{
description: '按 KT 多仓库约定生成提交计划:按仓库分组、建议 commit message、提交命令和提交前检查。',
inputSchema: {
includePush: z.boolean().default(false),
message: z.string().optional(),
projects: z.array(z.string()).default([...defaultReviewProjects]),
},
title: 'KT Commit Plan',
},
async (input) => response(await buildCommitPlan(input)),
);
server.registerTool(
'kt_push_plan',
{
description: '按 KT 多仓库约定生成推送计划检查本地状态、待推提交、push 命令和远程异常提醒。',
inputSchema: {
branch: z.string().optional(),
projects: z.array(z.string()).default([...defaultReviewProjects]),
remote: z.string().default('origin'),
},
title: 'KT Push Plan',
},
async (input) => response(await buildPushPlan(input)),
);
server.registerTool(
'kt_business_test_plan',
{
description: '生成 KT 固化业务链路测试计划Admin 登录、博客 CRUD、QQBot 扫码/自动回复、Web/Playground 鉴权回跳。',
inputSchema: {
baseUrl: z.string().optional(),
environment: z.enum(['local', 'remote']).default('local'),
flow: z
.enum([
'admin-login',
'blog-crud',
'qqbot-account-scan',
'qqbot-auto-reply',
'web-playground-auth',
'all',
])
.default('all'),
},
title: 'KT Business Test Plan',
},
async (input) => response(buildBusinessTestPlan(input)),
);
server.registerTool(
'kt_remote_health_check',
{
description: '生成或执行飞牛 NAS 远程只读健康检查SSH、Docker、Gitea、Jenkins、MySQL、MinIO、Mosquitto、NapCat、k3d/K8s。',
inputSchema: {
execute: z.boolean().default(false),
host: z.string().optional(),
port: z.number().int().min(1).max(65_535).default(2202),
services: z.array(z.string()).default([]),
sshTarget: z.string().optional(),
},
title: 'KT Remote Health Check',
},
async (input) => response(await buildRemoteHealthCheck(input)),
);
server.registerTool(
'kt_db_sync_plan',
{
description: '生成 KT 数据库同步安全向导:源/目标确认、GTID 规避、目标备份、行数校验和通过后删除备份库。',
inputSchema: {
backupName: z.string().optional(),
source: z.enum(['local', 'remote']).default('local'),
target: z.enum(['local', 'remote']).default('remote'),
},
title: 'KT DB Sync Plan',
},
async (input) => response(buildDbSyncPlan(input)),
);
server.registerTool(
'kt_component_workflow',
{
description: '按 KT 专项组件/链路输出防踩坑工作流KtTable、BlogArgon、AdminAuth、QQBot。',
inputSchema: {
target: z.enum(['AdminAuth', 'BlogArgon', 'KtTable', 'QQBot']).default('KtTable'),
},
title: 'KT Component Workflow',
},
async (input) => response(buildComponentWorkflow(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)),
);
}

74
src/selfTest.ts Normal file
View File

@ -0,0 +1,74 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { registeredToolNames } from './core/constants.js';
import { resolveInsideRoot } from './core/workspace.js';
import { buildGuardrails } from './tools/guardrails.js';
import { cleanupHistoryArtifacts } from './tools/cleanup.js';
import { inspectProject } from './tools/inspect.js';
import { prepareTask, readWorkflowContext } from './tools/task.js';
import { buildGlobalCodeReview } from './tools/review.js';
import { buildBusinessTestPlan } from './tools/testing.js';
import { buildVerificationPlan } from './tools/verification.js';
import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js';
export async function runSelfTest(): Promise<void> {
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 可复用能力',
}),
review: await buildGlobalCodeReview({
includeContentScan: true,
includeRootScan: true,
maxFindingsPerProject: 10,
projects: ['mcp'],
}),
businessTestPlan: buildBusinessTestPlan({
flow: 'qqbot-auto-reply',
}),
commitPlan: await buildCommitPlan({
projects: ['mcp'],
}),
componentWorkflow: buildComponentWorkflow({
target: 'KtTable',
}),
dbSyncPlan: buildDbSyncPlan({}),
finishTask: await buildFinishTask({
cleanupHistory: false,
projects: ['mcp'],
runValidation: false,
}),
pushPlan: await buildPushPlan({
projects: ['mcp'],
}),
remoteHealthCheck: await buildRemoteHealthCheck({
execute: false,
services: ['ssh', 'docker'],
}),
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));
}

File diff suppressed because it is too large Load Diff

132
src/tools/cleanup.ts Normal file
View File

@ -0,0 +1,132 @@
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
import path from 'node:path';
import type { CleanupHistoryInput, CleanupProcessPlanInput } from '../types.js';
import { defaultHistoryRoots } from '../core/constants.js';
import { resolveInsideRoot, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
export function createCleanupProcessPlan(input: CleanupProcessPlanInput): Record<string, unknown> {
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 <pid> 结束。',
...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<string>): 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);
}
export function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record<string, unknown> {
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<Omit<HistoryEntry, 'mtimeMs' | 'path'>>,
dryRun,
keep,
preserved: [] as Array<Omit<HistoryEntry, 'mtimeMs' | 'path'>>,
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;
}
export 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],
};
}

107
src/tools/envRisk.ts Normal file
View File

@ -0,0 +1,107 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { EnvPolicyInput, RiskScanInput } from '../types.js';
import { tryExecFile } from '../core/exec.js';
import { detectRepoType } from '../core/project.js';
import { readTextIfExists, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
export function buildEnvPolicy(input: EnvPolicyInput): Record<string, unknown> {
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 忽略。`),
};
}
export async function scanTaskRisk(input: RiskScanInput): Promise<Record<string, unknown>> {
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。',
],
};
}

75
src/tools/guardrails.ts Normal file
View File

@ -0,0 +1,75 @@
import type { GuardrailSet, GuardrailsInput, GuardrailsResult } from '../types.js';
import { resolveProject } from '../core/workspace.js';
import { createPageTestCase } from './testing.js';
import { buildVerificationPlan } from './verification.js';
export 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('接口改完必须本地真实调用一次对应接口。');
}
guardrails.verification.notes.push(
'改动后的 review 是硬步骤,优先用 kt_global_code_review 或 pnpm run global-review。',
);
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 || '',
};
}

64
src/tools/inspect.ts Normal file
View File

@ -0,0 +1,64 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { InspectAllProjectsInput, InspectProjectInput } from '../types.js';
import { projectAliases } from '../core/constants.js';
import { tryExecFile } from '../core/exec.js';
import { detectPackageManager, detectRepoType } from '../core/project.js';
import { listImmediateChildren, resolveProject, workspaceRoot } from '../core/workspace.js';
export async function inspectProject(input: InspectProjectInput): Promise<Record<string, unknown>> {
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,
};
}
export async function inspectAllProjects(input: InspectAllProjectsInput = {}): Promise<Record<string, unknown>> {
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,
};
}

365
src/tools/review.ts Normal file
View File

@ -0,0 +1,365 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import type { GlobalCodeReviewInput, ProjectAlias, RepoType, ResolvedProject } from '../types.js';
import { tryExecFile } from '../core/exec.js';
import { detectRepoType } from '../core/project.js';
import { resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
import { scanTaskRisk } from './envRisk.js';
export const defaultReviewProjects = [
'api',
'admin',
'blog',
'web',
'playground',
'mcp',
] as const;
const reviewSkipDirectoryNames = new Set([
'.git',
'.turbo',
'.vite',
'coverage',
'dist',
'node_modules',
'playwright-report',
'test-results',
]);
const reviewSkipFilePattern =
/(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|.*\.map|.*\.png|.*\.jpg|.*\.jpeg|.*\.gif|.*\.webp|.*\.zip|.*\.tar|.*\.gz)$/i;
const sensitiveTrackedFilePattern =
/(^|\/)(\.env($|\..+)|deploy\.json|secrets?\.(env|json|ya?ml)|id_rsa|[^/]+\.(pem|key))$/i;
const allowedSensitiveTrackedFilePattern = /(^|\/)\.env\.example$/i;
const credentialAssignmentPattern =
/\b(password|passwd|secret|token|webuiToken|accessToken|privateKey)\b\s*[:=]\s*(["'])([^"']+)\2/i;
interface ReviewFinding {
category: string;
file?: string;
line?: number;
level: 'P1' | 'P2' | 'P3';
message: string;
project: string;
suggestion: string;
}
interface ReviewProjectResult {
changedFiles: string[];
credentialHints: ReviewFinding[];
debugFindings: ReviewFinding[];
findings: ReviewFinding[];
project: ResolvedProject;
repoType: RepoType;
riskScan: Record<string, unknown>;
status: string;
trackedSensitiveFiles: string[];
}
export function parseGitStatusFiles(status: string): string[] {
return status
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean)
.map((line) => {
const normalized = line.trimStart();
const match = normalized.match(/^[ MADRCU?!]{1,2}\s+(.+)$/);
const file = (match?.[1] || line.slice(3)).trim();
return file.includes(' -> ') ? file.split(' -> ').at(-1)!.trim() : file;
})
.filter(Boolean);
}
async function listGitTrackedFiles(projectPath: string): Promise<string[]> {
const tracked = await tryExecFile('git', ['ls-files'], projectPath);
if (!tracked.ok) return [];
return tracked.stdout
.split(/\r?\n/)
.map((file) => file.trim())
.filter(Boolean)
.map((file) => file.replaceAll('\\', '/'));
}
function shouldReadReviewFile(file: string): boolean {
const normalized = file.replaceAll('\\', '/');
return !reviewSkipFilePattern.test(normalized);
}
function readProjectFileLines(projectPath: string, file: string): string[] {
const filePath = path.join(projectPath, file);
if (!existsSync(filePath)) return [];
try {
const stats = statSync(filePath);
if (!stats.isFile() || stats.size > 1024 * 1024) return [];
return readFileSync(filePath, 'utf8').split(/\r?\n/);
} catch {
return [];
}
}
function isBenignCredentialValue(value: string): boolean {
const normalized = value.trim().replace(/^["']|["']$/g, '').toLowerCase();
if (!normalized) return true;
if (normalized.startsWith('$') || normalized.startsWith('${')) return true;
if (normalized.startsWith('<') || normalized.includes('example')) return true;
return [
'false',
'null',
'password',
'please-replace-me-with-your-own-key',
'secret',
'token',
'true',
'undefined',
'your-token',
].includes(normalized);
}
function isRuntimeDebugAllowed(projectAlias: ProjectAlias | null, file: string): boolean {
const normalized = file.replaceAll('\\', '/');
if (/^(README|skills\/|.*\.md$)/.test(normalized)) return true;
if (projectAlias === 'admin' && normalized.startsWith('internal/vite-config/')) {
return true;
}
if (projectAlias === 'mcp' && /^(scripts\/|src\/server\.ts$)/.test(normalized)) {
return true;
}
return false;
}
function isCredentialHintAllowed(file: string, line: string): boolean {
const normalized = file.replaceAll('\\', '/');
const trimmed = line.trimStart();
if (trimmed.startsWith('<')) return true;
return /(^|\/)views\/(demos|examples)\//.test(normalized);
}
function collectProjectContentFindings(
project: ResolvedProject,
trackedFiles: string[],
includeContentScan: boolean,
maxFindings: number,
): {
credentialHints: ReviewFinding[];
debugFindings: ReviewFinding[];
findings: ReviewFinding[];
} {
const findings: ReviewFinding[] = [];
const credentialHints: ReviewFinding[] = [];
const debugFindings: ReviewFinding[] = [];
for (const file of trackedFiles.filter(shouldReadReviewFile)) {
const lines = readProjectFileLines(project.path, file);
if (lines.length === 0) continue;
lines.forEach((line, index) => {
if (/^(<<<<<<<|=======|>>>>>>>) /.test(line)) {
findings.push({
category: 'conflict-marker',
file,
line: index + 1,
level: 'P1',
message: '发现 Git 冲突标记。',
project: project.relativePath,
suggestion: '先解决冲突标记,再继续构建或提交。',
});
}
if (!isRuntimeDebugAllowed(project.alias, file) && /\bdebugger;|console\.log\(/.test(line)) {
debugFindings.push({
category: 'runtime-debug',
file,
line: index + 1,
level: /\bdebugger;/.test(line) ? 'P2' : 'P3',
message: '运行时代码中存在调试输出。',
project: project.relativePath,
suggestion: '页面或服务运行时代码不要保留 console.log/debugger。',
});
}
if (includeContentScan) {
const credentialMatch = line.match(credentialAssignmentPattern);
if (
credentialMatch &&
!allowedSensitiveTrackedFilePattern.test(file) &&
!isCredentialHintAllowed(file, line) &&
!isBenignCredentialValue(credentialMatch[3])
) {
credentialHints.push({
category: 'credential-literal',
file,
line: index + 1,
level: 'P2',
message: `疑似 ${credentialMatch[1]} 字面量赋值。`,
project: project.relativePath,
suggestion: '确认是否为真实凭据;真实值应移入本地 env 或远程 Secret。',
});
}
}
});
if (
findings.length + credentialHints.length + debugFindings.length >=
maxFindings * 3
) {
break;
}
}
return {
credentialHints: credentialHints.slice(0, maxFindings),
debugFindings: debugFindings.slice(0, maxFindings),
findings: findings.slice(0, maxFindings),
};
}
function findWorkspaceFilesByName(names: Set<string>, maxResults: number): string[] {
const results: string[] = [];
const walk = (currentPath: string) => {
if (results.length >= maxResults) return;
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
if (results.length >= maxResults) return;
if (entry.isDirectory()) {
if (reviewSkipDirectoryNames.has(entry.name)) continue;
walk(path.join(currentPath, entry.name));
continue;
}
if (entry.isFile() && names.has(entry.name)) {
results.push(toPosix(path.relative(workspaceRoot, path.join(currentPath, entry.name))));
}
}
};
walk(workspaceRoot);
return results;
}
async function reviewProject(
projectKey: string,
input: Required<Pick<GlobalCodeReviewInput, 'includeContentScan' | 'maxFindingsPerProject'>>,
): Promise<ReviewProjectResult> {
const project = resolveProject(projectKey);
const repoType = detectRepoType(project.path);
const status =
repoType === 'git'
? await tryExecFile('git', ['status', '--short'], project.path)
: { ok: false, stderr: '', stdout: '' };
const changedFiles = status.ok ? parseGitStatusFiles(status.stdout) : [];
const trackedFiles = repoType === 'git' ? await listGitTrackedFiles(project.path) : [];
const trackedSensitiveFiles = trackedFiles.filter(
(file) =>
sensitiveTrackedFilePattern.test(file) &&
!allowedSensitiveTrackedFilePattern.test(file),
);
const contentFindings = collectProjectContentFindings(
project,
trackedFiles,
input.includeContentScan,
input.maxFindingsPerProject,
);
const trackedSensitiveFindings = trackedSensitiveFiles
.slice(0, input.maxFindingsPerProject)
.map<ReviewFinding>((file) => ({
category: 'tracked-sensitive-file',
file,
level: 'P1',
message: '仓库仍跟踪真实环境或部署凭据类文件。',
project: project.relativePath,
suggestion: '使用 git rm --cached 移出索引,并提交 example 文件或文档说明。',
}));
return {
changedFiles,
credentialHints: contentFindings.credentialHints,
debugFindings: contentFindings.debugFindings,
findings: [...trackedSensitiveFindings, ...contentFindings.findings],
project,
repoType,
riskScan: await scanTaskRisk({
changedFiles,
project: projectKey,
}),
status: status.ok ? status.stdout : status.stderr,
trackedSensitiveFiles,
};
}
export async function buildGlobalCodeReview(
input: GlobalCodeReviewInput = {},
): Promise<Record<string, unknown>> {
const projects =
(input.projects?.length ?? 0) > 0
? input.projects!
: [...defaultReviewProjects];
const includeContentScan = input.includeContentScan ?? true;
const maxFindingsPerProject = Math.max(
1,
Math.min(input.maxFindingsPerProject ?? 20, 200),
);
const projectResults = await Promise.all(
projects.map((project) =>
reviewProject(project, {
includeContentScan,
maxFindingsPerProject,
}),
),
);
const rootLegacyFiles =
input.includeRootScan === false
? []
: findWorkspaceFilesByName(new Set(['deploy.js', 'deploy.json']), 50);
const findings = projectResults.flatMap((item) => [
...item.findings,
...item.debugFindings,
...item.credentialHints,
]);
const rootFindings = rootLegacyFiles.map<ReviewFinding>((file) => ({
category: 'legacy-deploy-file',
file,
level: 'P2',
message: '发现旧部署遗留文件。',
project: 'root',
suggestion: '确认是否仍被脚本引用;无用则删除并从 Git 索引移除。',
}));
const allFindings = [...findings, ...rootFindings];
return {
findings: allFindings,
generatedAt: new Date().toISOString(),
notes: [
'本工具只读扫描,不会修改文件、删除文件或提交代码。',
'P1 优先处理:冲突标记、被跟踪的真实 env/部署凭据文件。',
'P2 需要人工确认:疑似凭据字面量、旧部署文件、调试断点。',
'P3 为清洁度问题:运行时 console.log 等。',
],
projects: projectResults.map((item) => ({
changedFiles: item.changedFiles,
credentialHintCount: item.credentialHints.length,
debugFindingCount: item.debugFindings.length,
findingCount: item.findings.length,
project: item.project,
repoType: item.repoType,
riskScan: item.riskScan,
status: item.status,
trackedSensitiveFiles: item.trackedSensitiveFiles,
})),
summary: {
changedProjectCount: projectResults.filter((item) => item.changedFiles.length > 0)
.length,
findingCount: allFindings.length,
p1: allFindings.filter((item) => item.level === 'P1').length,
p2: allFindings.filter((item) => item.level === 'P2').length,
p3: allFindings.filter((item) => item.level === 'P3').length,
reviewedProjects: projectResults.length,
rootLegacyFiles,
},
workspaceRoot,
};
}

146
src/tools/task.ts Normal file
View File

@ -0,0 +1,146 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import type { CommitChecklistInput, PrepareTaskInput, TaskRecordInput, WorkflowContextInput } from '../types.js';
import { projectAliases } from '../core/constants.js';
import { detectRepoType } from '../core/project.js';
import { formatDateInShanghai, getRecentTaskRecords, readText, resolveInsideRoot, resolveProject, workspaceRoot } from '../core/workspace.js';
import { buildGuardrails } from './guardrails.js';
import { inspectProject } from './inspect.js';
import { scanTaskRisk } from './envRisk.js';
import { createPageTestCase } from './testing.js';
export async function prepareTask(input: PrepareTaskInput): Promise<Record<string, unknown>> {
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。',
'最终回复或提交前跑全局 review并处理全部 findings。',
],
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 || '',
};
}
export 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`;
}
export function appendTaskRecord(input: TaskRecordInput): Record<string, unknown> {
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,
};
}
export async function createCommitChecklist(input: CommitChecklistInput): Promise<Record<string, unknown>> {
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: [
'确认只包含本次任务相关文件。',
'先运行对应轻量验证命令。',
'提交前已跑全局 review且 findings 已修复或明确记录为非阻塞风险。',
'每个独立 Git 仓库单独 commit。',
'没有用户明确要求时不 push。',
],
inspection,
message,
messageOk,
messageRule: '英文 type 前缀 + 中文描述,例如 feat: 功能性提交',
};
}
export function readWorkflowContext(input: WorkflowContextInput): Record<string, unknown> {
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,
};
}

155
src/tools/testing.ts Normal file
View File

@ -0,0 +1,155 @@
import type { ApiTestPlanInput, BusinessFlow, BusinessTestPlanInput, PageTestCaseInput, PageTestCaseResult } from '../types.js';
import { resolveProject } from '../core/workspace.js';
export 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,
};
}
export function createApiTestPlan(input: ApiTestPlanInput): Record<string, unknown> {
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 <accessToken>"');
}
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,
};
}
export function buildBusinessTestPlan(input: BusinessTestPlanInput = {}): Record<string, unknown> {
const environment = input.environment || 'local';
const baseUrl = input.baseUrl || (environment === 'remote' ? 'https://待填写' : 'http://127.0.0.1');
const flows: Record<BusinessFlow, Record<string, unknown>> = {
'admin-login': {
assertions: [
'登录接口返回 code=200 且 data 内有 accessToken/userInfo/menu/permissions。',
'Admin 页面进入后后端路由菜单正常渲染。',
'WordPress/NAS 不可用时不阻塞 Admin 登录。',
],
preflight: ['API 可达', 'Admin dev server 可达', '数据库管理员账号存在'],
projects: ['api', 'admin'],
steps: [
'调用 /auth/login 或使用 admin-login 脚本固化登录态。',
'进入 Admin 首页,确认菜单与按钮权限。',
'刷新页面后确认 auth 持久化仍有效。',
],
},
'blog-crud': {
assertions: [
'文章、分类、标签列表返回统一 code/msg/data。',
'新增/编辑/删除后后端刷新 WordPress 缓存。',
'文章编辑能绑定分类和标签,查询不额外打开新 tab。',
],
preflight: ['API 可达', 'WordPress 管理端认证可用或降级逻辑明确', 'Admin 博客菜单有权限'],
projects: ['api', 'admin', 'blog'],
steps: ['登录 Admin', '新增分类/标签', '新增文章并绑定分类标签', '编辑后查询列表', '清理 KT_TEST_ 数据'],
},
'qqbot-account-scan': {
assertions: [
'扫码创建返回 qrcode/containerName/webuiPort。',
'取消/过期/失败会清理未绑定 NapCat 容器。',
'删除账号会二次确认并删除专属容器。',
],
preflight: ['NAS SSH 可达', 'NapCat WebUI 可达', 'API QQBot env 配置完整'],
projects: ['api', 'admin'],
steps: ['打开账号连接页', '点击扫码新增账号', '轮询二维码状态', '确认账号回填', '测试删除联动容器'],
},
'qqbot-auto-reply': {
assertions: [
'消息日志能看到 OneBot 收到的私聊/群聊/频道消息。',
'权限名单按 QQ号、群/频道、精确 QQ 过滤生效。',
'规则命中后发送日志记录成功或失败原因。',
],
preflight: ['账号在线', 'MQTT 或 local bus 正常', '规则启用', '名单配置符合测试目标'],
projects: ['api', 'admin'],
steps: ['创建 KT_TEST_ 自动回复规则', '发送私聊测试消息', '查询消息日志', '查询发送日志', '清理测试规则'],
},
'web-playground-auth': {
assertions: [
'无 token 时不主动 refresh不产生登录循环。',
'Admin 已登录时能带回授权态并重定向回原页面。',
'Playground 保存组件时截图上传 MinIO 并把 image 写入 API。',
],
preflight: ['Admin/API/Web/Playground 地址可达', '登录态存储为空或可控', 'MinIO 可达'],
projects: ['api', 'admin', 'web', 'playground'],
steps: ['清理本地 token', '从 Web/Playground 进入受保护页面', '跳 Admin 登录', '登录后回跳', '保存组件并查询接口'],
},
};
const selected: Array<[string, Record<string, unknown>]> =
!input.flow || input.flow === 'all'
? Object.entries(flows)
: [[input.flow, flows[input.flow] as Record<string, unknown>]];
return {
baseUrl,
environment,
maxRounds: 3,
protocol: [
'业务测试先写用例,再按环境 -> 账号 -> 数据 -> 接口 -> 页面预检。',
'失败必须记录复现步骤、证据和初步归因。',
'同一用例最多三轮,第三轮仍失败则停止等待建议。',
],
selectedFlows: selected.map(([name, plan]) => ({
name,
...plan,
})),
};
}

76
src/tools/verification.ts Normal file
View File

@ -0,0 +1,76 @@
import type { VerificationPlanInput, VerificationPlanResult } from '../types.js';
import { detectPackageManager, detectRepoType, getTypecheckCommand } from '../core/project.js';
import { getGlobalReviewCommand, resolveProject } from '../core/workspace.js';
export 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。',
'只要产生文件改动,最终回复或提交前必须走一次 review有 findings 先修复再复跑。',
];
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', 'mcp', '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');
commands.push(getGlobalReviewCommand());
return {
changeType,
commands: Array.from(new Set(commands)),
notes,
project,
repoType,
};
}

316
src/tools/workflow.ts Normal file
View File

@ -0,0 +1,316 @@
import type { CommitPlanInput, ComponentWorkflowInput, DbSyncPlanInput, ExecResult, FinishTaskInput, PushPlanInput, RemoteHealthCheckInput, RepoType, ResolvedProject } from '../types.js';
import { tryExecFile, tryPowerShell } from '../core/exec.js';
import { detectRepoType } from '../core/project.js';
import { formatDateInShanghai, getGlobalReviewCommand, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
import { cleanupHistoryArtifacts } from './cleanup.js';
import { buildGlobalCodeReview, defaultReviewProjects, parseGitStatusFiles } from './review.js';
import { buildVerificationPlan } from './verification.js';
interface RepoSnapshot {
branch: string;
changedFiles: string[];
project: ResolvedProject;
remote: string;
repoType: RepoType;
status: string;
}
function normalizeProjectKeys(projects?: string[]): string[] {
const selected = (projects?.length ?? 0) > 0 ? projects! : [...defaultReviewProjects];
return Array.from(new Set(selected.filter((project) => project !== 'root')));
}
async function collectRepoSnapshot(projectKey: string): Promise<RepoSnapshot> {
const project = resolveProject(projectKey);
const repoType = detectRepoType(project.path);
const status = repoType === 'git'
? await tryExecFile('git', ['status', '--short'], project.path)
: null;
const branch = repoType === 'git'
? await tryExecFile('git', ['branch', '--show-current'], project.path)
: null;
const remote = repoType === 'git'
? await tryExecFile('git', ['remote', '-v'], project.path)
: null;
return {
branch: branch?.ok ? branch.stdout : '',
changedFiles: status?.ok ? parseGitStatusFiles(status.stdout) : [],
project,
remote: remote?.ok ? remote.stdout : '',
repoType,
status: status?.ok ? status.stdout : status?.stderr || '',
};
}
function suggestCommitMessage(snapshot: RepoSnapshot): string {
const files = snapshot.changedFiles.join('\n');
if (snapshot.project.alias === 'mcp') {
return 'feat(workflow): 扩展KT工作流能力';
}
if (snapshot.project.alias === 'api' && /qqbot/i.test(files)) {
return 'fix(qqbot): 修复QQBot运行链路';
}
if (snapshot.project.alias === 'admin' && /\.env|authentication/.test(files)) {
return 'fix(admin): 收敛环境配置和登录页';
}
if (snapshot.project.alias === 'web' && /deploy\.json|\.gitignore/.test(files)) {
return 'chore(web): 清理旧部署配置';
}
if (snapshot.project.alias === 'blog') {
return 'fix(blog): 优化博客前台体验';
}
return `chore(${snapshot.project.alias || 'repo'}): 更新项目改动`;
}
export async function buildFinishTask(input: FinishTaskInput = {}): Promise<Record<string, unknown>> {
const projectKeys = normalizeProjectKeys(input.projects);
const snapshots = await Promise.all(projectKeys.map(collectRepoSnapshot));
const changedSnapshots = snapshots.filter((snapshot) => snapshot.changedFiles.length > 0);
const verificationPlans = changedSnapshots.map((snapshot) =>
buildVerificationPlan({
changeType: input.changeType || 'general',
project: snapshot.project.alias || snapshot.project.relativePath,
}),
);
const validationRuns: Array<{
command: string;
project: ResolvedProject;
result: ExecResult;
}> = [];
if (input.runValidation) {
for (const plan of verificationPlans) {
for (const command of plan.commands.filter((item) => !item.includes('global-review'))) {
validationRuns.push({
command,
project: plan.project,
result: await tryPowerShell(command, plan.project.path),
});
}
}
}
const review = await buildGlobalCodeReview({
includeContentScan: true,
includeRootScan: input.includeRootScan ?? true,
maxFindingsPerProject: input.maxFindingsPerProject ?? 20,
projects: projectKeys,
});
const cleanup = cleanupHistoryArtifacts({
dryRun: !(input.cleanupHistory ?? false),
keep: input.keepHistory ?? 3,
});
return {
cleanup,
mode: input.runValidation ? 'validation_executed' : 'plan_only',
notes: [
'默认只生成收尾结果runValidation=true 时才执行验证命令。',
'cleanupHistory=true 时才真实清理历史产物,否则仅预览。',
'提交和推送仍需用户明确要求后执行。',
],
review,
snapshots,
stopConditions: [
'review.findings 非空时先修复,再复跑 kt_global_code_review。',
'validationRuns 中任一 result.ok=false 时先处理失败命令。',
'发现真实 env、生产 Secret 或非本次范围文件时停止提交。',
],
summary: {
changedProjectCount: changedSnapshots.length,
changedProjects: changedSnapshots.map((snapshot) => snapshot.project.relativePath),
validationRunCount: validationRuns.length,
},
validationRuns,
verificationPlans,
};
}
export async function buildCommitPlan(input: CommitPlanInput = {}): Promise<Record<string, unknown>> {
const snapshots = await Promise.all(normalizeProjectKeys(input.projects).map(collectRepoSnapshot));
const changedSnapshots = snapshots.filter((snapshot) => snapshot.changedFiles.length > 0);
return {
checks: [
'每个独立 Git 仓库单独提交。',
'提交前先跑对应轻量验证和 kt_global_code_review。',
'确认 .env.development/.env.production 等真实配置只从索引移除,不提交真实值。',
'根目录 AGENTS/SKILLS/TASKS 不属于子仓库,最终回复里单独说明。',
],
includePush: input.includePush ?? false,
projects: changedSnapshots.map((snapshot) => {
const message = input.message || suggestCommitMessage(snapshot);
return {
branch: snapshot.branch,
changedFiles: snapshot.changedFiles,
commands: [
'git status --short',
'git add -A',
`git commit -m "${message}"`,
...(input.includePush ? [`git push origin ${snapshot.branch || 'main'}`] : []),
],
message,
project: snapshot.project,
status: snapshot.status,
};
}),
reviewCommand: getGlobalReviewCommand(),
summary: {
changedProjectCount: changedSnapshots.length,
totalChangedFiles: changedSnapshots.reduce(
(count, snapshot) => count + snapshot.changedFiles.length,
0,
),
},
};
}
export async function buildPushPlan(input: PushPlanInput = {}): Promise<Record<string, unknown>> {
const snapshots = await Promise.all(normalizeProjectKeys(input.projects).map(collectRepoSnapshot));
const remote = input.remote || 'origin';
return {
checks: [
'推送前工作区应干净;如仍有改动,先提交或说明为什么暂不提交。',
'推送前复跑 kt_global_code_reviewfindings 必须为 0 或明确非阻塞。',
'如果 Jenkins/Gitea host key、mirror、hooks 异常,先修远程仓库状态再重试。',
],
projects: snapshots
.filter((snapshot) => snapshot.repoType === 'git')
.map((snapshot) => {
const branch = input.branch || snapshot.branch || 'main';
return {
branch,
commands: [
'git status --short',
`git log --oneline ${remote}/${branch}..${branch}`,
`git push ${remote} ${branch}`,
],
project: snapshot.project,
remote: snapshot.remote,
status: snapshot.status,
};
}),
reviewCommand: getGlobalReviewCommand(),
};
}
function buildRemoteHealthCommands(input: RemoteHealthCheckInput = {}): Array<{
command: string;
service: string;
}> {
const host = input.sshTarget || input.host || process.env.KT_NAS_SSH_TARGET || 'nas.kwitsukasa.top';
const port = input.port ?? Number(process.env.KT_NAS_SSH_PORT || 2202);
const ssh = (remoteCommand: string) => `ssh -p ${port} ${host} "${remoteCommand}"`;
const allCommands: Record<string, string> = {
docker: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"),
gitea: ssh("docker ps --format '{{.Names}} {{.Status}}' | grep -i gitea || true"),
jenkins: ssh("docker ps --format '{{.Names}} {{.Status}}' | grep -i jenkins || true"),
k3d: ssh('kubectl get pods -A -o wide 2>/dev/null || k3d cluster list 2>/dev/null || true'),
minio: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i minio || true"),
mosquitto: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i mosquitto || true"),
mysql: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'mysql|mariadb' || true"),
napcat: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'napcat|qqbot' || true"),
ssh: ssh('hostname && date'),
};
const services = (input.services?.length ?? 0) > 0 ? input.services! : Object.keys(allCommands);
return services
.filter((service) => allCommands[service])
.map((service) => ({
command: allCommands[service],
service,
}));
}
export async function buildRemoteHealthCheck(input: RemoteHealthCheckInput = {}): Promise<Record<string, unknown>> {
const commands = buildRemoteHealthCommands(input);
const results = input.execute
? await Promise.all(
commands.map(async (item) => ({
...item,
result: await tryPowerShell(item.command, workspaceRoot),
})),
)
: [];
return {
commands,
execute: input.execute ?? false,
notes: [
'默认 execute=false只生成只读探测命令。',
'远程命令只做 hostname/date/docker ps/kubectl get 等只读检查。',
'真实密码、token、Secret 不从仓库读取,也不写入输出。',
],
results,
};
}
export function buildDbSyncPlan(input: DbSyncPlanInput = {}): Record<string, unknown> {
const source = input.source || 'local';
const target = input.target || 'remote';
const backupName = input.backupName || `kt_template_backup_${formatDateInShanghai().replaceAll('-', '')}`;
return {
backupName,
commands: [
'从 Node/kt-template-online-api/.env.development 和 .env.production 读取连接信息,只在本机 shell 环境使用,不写入 Git。',
'mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers <source-conn> kt_template > .codex-db-sync/kt_template.sql',
`mysql <target-conn> -e "CREATE DATABASE IF NOT EXISTS ${backupName} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"`,
`mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers <target-conn> kt_template | mysql <target-conn> ${backupName}`,
'mysql <target-conn> kt_template < .codex-db-sync/kt_template.sql',
'对比源库/目标库表数量、视图/函数/过程数量和关键表行数。',
`验证通过后再执行mysql <target-conn> -e "DROP DATABASE ${backupName};"`,
],
guardrails: [
'默认只生成计划不执行覆盖、drop、truncate。',
'同步前必须确认 source/target 方向,不能靠变量名猜。',
'目标库备份校验通过前不得删除备份库。',
'涉及生产库时优先只读验证,批量删除必须二次确认。',
],
source,
target,
verification: [
'SHOW TABLES 数量一致。',
'admin_user、admin_menu、qqbot_account、qqbot_message_log 等关键表行数符合预期。',
'应用登录和核心接口 smoke 通过。',
'全局 review findings=0。',
],
};
}
export function buildComponentWorkflow(input: ComponentWorkflowInput = {}): Record<string, unknown> {
const target = input.target || 'KtTable';
const workflows = {
AdminAuth: {
antiPatterns: ['无 token 时主动 refresh', '跨端回跳时清空 Admin 已有授权态', '把 redirect 写丢或重复编码'],
files: ['Vue/kt-template-admin/apps/web-antdv-next/src/store/auth.ts', 'Vue/kt-template-online-web/src/api/auth.ts', 'Vue/kt-template-online-playground/src/api/auth.ts'],
guardrails: ['先画清楚 Admin/API/Web/Playground token 链路', '401 时先判断旧 token 是否存在', 'Admin 已登录时直接带回授权态'],
verification: ['清空 token 测无循环', 'Admin 已登录测直接回跳', '刷新页面测持久化'],
},
BlogArgon: {
antiPatterns: ['使用具名 id 控制 DOM', 'SCSS 类名逃出 kt-blog 命名空间', '主题变量散落在 scss 文件', '亮色模式仍套暗色遮罩'],
files: ['Vue/kt-blog-web/src/hooks/useBlogTheme.ts', 'Vue/kt-blog-web/src/styles', 'Vue/kt-blog-web/src/components/blog'],
guardrails: ['样式统一 kt-blog BEM 命名空间', '主题变量收敛到 useBlogTheme', '动画用 Vue Transition', '和线上 Argon DOM/视觉逐项对照'],
verification: ['亮/暗模式切换', '搜索 Enter 和动画', '文章切换动画', '无感知滚动加载', '背景图滚动到底部不断层'],
},
KtTable: {
antiPatterns: ['自定义纵向滚动条覆盖 Antdv 原生滚动', 'scroll/resize 高频写响应式状态', '表格高度参与展开收起动画', '固定列透明导致透视'],
files: ['Vue/kt-template-admin/apps/web-antdv-next/src/components/ktTable'],
guardrails: ['优先使用 antdv-next 原生 table 能力', '统计列固定到底部但不参与表单动画', '序号列和操作列固定最小/最大宽', 'resize 用 RAF 节流和非深度监听'],
verification: ['横向滚动性能', '纵向滚动表头对齐', '列宽/行高 resize', 'summary 固定底部', '宽屏无无意义横向滚动条'],
},
QQBot: {
antiPatterns: ['把 NapCat token 写进仓库', '删除账号不清理专属容器', '白名单/黑名单只做前端过滤', '远程服务不可达时伪装成业务成功'],
files: ['Node/kt-template-online-api/src/qqbot', 'Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot', 'ci/fnos-k8s/deploy-qqbot-runtime.ps1'],
guardrails: ['OneBot 反向 WS token 校验', '权限名单底层先走 QQ号过滤', '群/频道精确 QQ 过滤由开关控制', '扫码取消/过期/失败清理未绑定容器'],
verification: ['账号在线', '扫码新增/更新登录', '自动回复规则命中', '权限名单三类过滤', '消息日志和发送日志闭环'],
},
} satisfies Record<string, Record<string, unknown>>;
return {
target,
workflow: workflows[target],
};
}

View File

@ -126,6 +126,66 @@ export interface RiskScanInput {
project?: string;
}
export interface GlobalCodeReviewInput {
includeContentScan?: boolean;
includeRootScan?: boolean;
maxFindingsPerProject?: number;
projects?: string[];
}
export interface FinishTaskInput {
changeType?: TaskType;
cleanupHistory?: boolean;
includeRootScan?: boolean;
keepHistory?: number;
maxFindingsPerProject?: number;
projects?: string[];
runValidation?: boolean;
}
export interface CommitPlanInput {
includePush?: boolean;
message?: string;
projects?: string[];
}
export interface PushPlanInput {
branch?: string;
projects?: string[];
remote?: string;
}
export type BusinessFlow =
| 'admin-login'
| 'blog-crud'
| 'qqbot-account-scan'
| 'qqbot-auto-reply'
| 'web-playground-auth';
export interface BusinessTestPlanInput {
baseUrl?: string;
environment?: 'local' | 'remote';
flow?: BusinessFlow | 'all';
}
export interface RemoteHealthCheckInput {
execute?: boolean;
host?: string;
port?: number;
services?: string[];
sshTarget?: string;
}
export interface DbSyncPlanInput {
backupName?: string;
source?: 'local' | 'remote';
target?: 'local' | 'remote';
}
export interface ComponentWorkflowInput {
target?: 'AdminAuth' | 'BlogArgon' | 'KtTable' | 'QQBot';
}
export interface PrepareTaskInput extends GuardrailsInput {
changedFiles?: string[];
entryUrl?: string;