diff --git a/README.md b/README.md index ac78eee..c4212a5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## 能力边界 - 读取 KT 工作区紧凑上下文:项目清单、硬性规则、skill registry、标准 skill 包索引、历史关键词、最近任务记录和详细文档入口;需要全量文档时显式开启。 +- 读取 Obsidian 索引上下文:从 `docs/obsidian` 的总入口、模块索引、文档矩阵、工作流关系和 Canvas 摘要中按模块或关键词定位文档,并返回 `codeAnchors` / `ruleAnchors` 锚定到现有项目别名、源码路径、相关规则文档和后续上下文工具。 - 检查子项目环境:Git/SVN、包管理器、Node 版本、env 文件、Git 状态。 - 生成防偏差工作包:把开工前检查、禁止项、风险扫描、验证计划汇总成一份可执行清单。 - 生成验证建议:按后端、前端、样式、页面、部署、MCP 等变更类型给出轻量验证命令。 @@ -15,11 +16,12 @@ - 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。 - 生成业务链路测试计划:固化 Admin 登录、博客 CRUD、QQBot 扫码/自动回复、更新登录 SSE、FFLogs 命令、系统日志可视化、Web/Playground 回跳。 - 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。 +- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口。 - 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。 - 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、备份和行数校验。 - 生成专项组件工作流:KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 的防踩坑清单和验证点。 - 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。 -- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留。 +- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。 - 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、部署链路和 Vue TSX 插槽写法等高风险改动。 - 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、根目录生成产物和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签,避免历史误报污染上下文;任何文件改动后都要跑一遍。 - 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。 @@ -32,8 +34,12 @@ cd D:/MyFiles/KT/mcp/ktWorkflow pnpm install pnpm run typecheck pnpm run self-test +pnpm run obsidian-context -- --module ktWorkflow +pnpm run obsidian-validate +pnpm run obsidian-sync pnpm run workstream-closeout -- --title "发布闭环" --verification "Jenkins SUCCESS" --problem "无新卡点" --solution "无新增稳定解法" pnpm run cleanup-history -- --dry-run +pnpm run cleanup-history -- --execute pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login ``` @@ -80,6 +86,8 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login | `kt_global_code_review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅深扫变更文件 | | `kt_finish_task` | 生成任务收尾包,可选执行验证和历史清理 | | `kt_workstream_closeout` | 生成大方向完结闭环审计,检查测试证据、问题记录、稳定解法和 ktWorkflow 升级 | +| `kt_workflow_loop_audit` | 审计上下文锚定、测试证据、文档同步、历史清理、问题固化、ktWorkflow 升级和全局 review | +| `kt_change_doc_sync` | 根据改动路径生成 README/API/AGENTS/docs/Obsidian/skill/ktWorkflow 文档同步清单 | | `kt_commit_plan` | 生成多仓库提交计划、建议 commit message 和检查项 | | `kt_push_plan` | 生成多仓库推送计划和远程异常提醒 | | `kt_business_test_plan` | 生成固化业务链路测试计划,包含 QQBot SSE、FFLogs 和系统日志 | @@ -87,6 +95,9 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login | `kt_remote_health_check` | 生成或执行远程只读健康检查命令 | | `kt_db_sync_plan` | 生成数据库同步安全向导 | | `kt_component_workflow` | 输出专项组件/链路防踩坑工作流,包含 FnosK8s | +| `kt_obsidian_context` | 读取 Obsidian 索引上下文,支持按 `module` / `query` 返回模块页、文档矩阵、Canvas 摘要、代码锚点和相关规则文档 | +| `kt_obsidian_validate` | 只读校验 Obsidian JSON、Canvas、Base、Wiki 链接、Markdown 相对链接和旧引用 | +| `kt_obsidian_sync` | 审计 Obsidian 工作流入口、书签、核心插件、忽略目录和校验结果 | | `kt_append_task_record` | 预览或写入 `TASKS.md` 最近记录 | | `kt_commit_checklist` | 生成提交前检查清单并校验 commit message | @@ -96,6 +107,10 @@ 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 复审报告;确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 | +| `pnpm run obsidian-context` | 输出 Obsidian 索引上下文;可传 `--module Admin`、`--query QQBot`、`--max-documents 10`。 | +| `pnpm run obsidian-validate` | 校验 KT Obsidian vault 结构和链接;默认 warning 不让脚本失败,需要严格模式时传 `--fail-on-warnings`。 | +| `pnpm run obsidian-sync` | 审计 Obsidian 工作流入口是否连通,并联动执行 validate。 | +| `pnpm run cleanup-history` | 预览 `.kt-workspace` 测试/验证历史产物清理;真实清理用 `pnpm run cleanup-history -- --execute`,只保留最近 3 轮并保留模板目录。 | | `pnpm run workstream-closeout` | 生成大方向完结闭环审计;识别测试证据、问题记录、稳定解法和 ktWorkflow 升级缺口。 | ## 项目别名 @@ -125,10 +140,13 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login ## 使用建议 - 写代码前先调用 `kt_prepare_task`;只需要单项信息时再调用 `kt_read_context` 或 `kt_inspect_project`。`kt_read_context` 默认紧凑输出,只有确实需要完整 `TASKS.md` 时再传 `includeFullDocs=true`。 +- 需要按知识图谱定位上下文时先调用 `kt_obsidian_context`,例如 `module=Admin` 或 `query=BangDream`;Obsidian 只做文档索引,返回的 `ruleAnchors` 用来读取相关规则文档,`codeAnchors` 必须继续搭配 `kt_inspect_project`、`kt_risk_scan`、`kt_suggest_verification` 锚定真实代码上下文。 - 多项目联动时先调用 `kt_inspect_all_projects`。 - 要收尾时调用 `kt_finish_task`,默认只生成计划和 review;需要执行验证时显式传 `runValidation=true`。 - 大方向结束前调用 `kt_workstream_closeout` 或 `pnpm run workstream-closeout`;如果识别到测试流、卡点、误报、清理、部署观测或命令模板,要先升级对应 ktWorkflow 规则再报告完成。 - 文件改动完成并验证后调用 `kt_global_code_review`,或运行 `pnpm run global-review`;它只读扫描,不删除文件、不提交代码。`findings` 先判定真实风险或工具误报,真实风险修业务代码,误报修 `mcp/ktWorkflow/src/tools/review.ts` 并复跑。 +- 代码或配置改动后调用 `kt_change_doc_sync`,把需要同步的 README/API/AGENTS/docs/Obsidian/skill/ktWorkflow 入口补齐;无需同步时把原因写进收尾证据。 +- 报告非平凡任务完成前调用 `kt_workflow_loop_audit`,确认测试证据、文档同步、历史清理、问题固化、ktWorkflow 升级和全局 review 都过门。 - 要提交或推送时先调用 `kt_commit_plan` / `kt_push_plan`,按仓库分组确认范围。 - 要测真实业务链路时调用 `kt_business_test_plan`,选择 `admin-login`、`qqbot-auto-reply`、`qqbot-login-sse`、`fflogs-command`、`system-log-visualization` 等 flow。 - 遇到同一命令或同一远程步骤重复卡住时调用 `kt_blocker_resolution`;第二次仍失败时先写入 `TASKS.md` 或补成脚本,再继续。 @@ -141,6 +159,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login - Admin 页面测试前可先执行 `pnpm run admin-login -- --url ` 固化登录态,输出的 `storageState` 可作为后续 Playwright 用例前置状态。 - 接口改动后调用 `kt_api_test_plan`,并真实请求一次接口。 - 验证启动过本地服务后调用 `kt_cleanup_process_plan`,清掉本次进程。 -- 每轮测试/验证结束后调用 `kt_cleanup_history`,或运行 `pnpm run cleanup-history -- --keep=3`,让 `.kt-workspace` 历史产物只保留最近 3 轮。 +- 每轮测试/验证结束后先调用 `kt_cleanup_history`,或运行 `pnpm run cleanup-history -- --dry-run`;如果预览里 `deleted` 非空,确认范围后执行 `pnpm run cleanup-history -- --execute`,让 `.kt-workspace` 历史产物只保留最近 3 轮并保留模板目录。 +- 更新 Obsidian 图谱、模块页、文档矩阵或 `.obsidian` 配置后运行 `kt_obsidian_validate` / `pnpm run obsidian-validate`;整理工作流入口时再跑 `kt_obsidian_sync`。 - 改完文件后用 `kt_append_task_record` 先 `dryRun` 预览记录,再决定是否写入。 - 提交前调用 `kt_commit_checklist`,确认文件范围和提交信息。 diff --git a/package.json b/package.json index 61de569..b98e187 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "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", + "obsidian-context": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-context", + "obsidian-sync": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-sync", + "obsidian-validate": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-validate", "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", diff --git a/src/core/cli.ts b/src/core/cli.ts index 9793f80..95c5a56 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -1,4 +1,11 @@ -import type { GlobalCodeReviewInput, WorkstreamCloseoutInput, WorkstreamReusablePattern } from '../types.js'; +import type { + GlobalCodeReviewInput, + ObsidianContextInput, + ObsidianSyncInput, + ObsidianValidateInput, + WorkstreamCloseoutInput, + WorkstreamReusablePattern, +} from '../types.js'; import { workstreamReusablePatternValues } from './constants.js'; function parseContentScanMode(argv: string[]): 'all' | 'changed' { @@ -70,3 +77,31 @@ export function parseWorkstreamCloseoutCliArgs(argv: string[]): WorkstreamCloseo verificationEvidence: readOptionList(argv, ['--verification', '--verification-evidence']), }; } + +export function parseObsidianCliArgs(argv: string[]): ObsidianValidateInput { + return { + checkLayout: !argv.includes('--no-layout'), + failOnWarnings: argv.includes('--fail-on-warnings'), + includeMarkdownLinks: !argv.includes('--no-markdown-links'), + includeStaleReferences: !argv.includes('--no-stale-references'), + }; +} + +export function parseObsidianSyncCliArgs(argv: string[]): ObsidianSyncInput { + return { + ...parseObsidianCliArgs(argv), + includeValidation: !argv.includes('--no-validation'), + }; +} + +export function parseObsidianContextCliArgs(argv: string[]): ObsidianContextInput { + const maxDocuments = Number(readOption(argv, ['--max-documents', '--maxDocuments'])); + + return { + includeCanvas: !argv.includes('--no-canvas'), + includeContent: !argv.includes('--no-content'), + maxDocuments: Number.isFinite(maxDocuments) ? maxDocuments : undefined, + module: readOption(argv, ['--module']), + query: readOption(argv, ['--query']), + }; +} diff --git a/src/core/constants.ts b/src/core/constants.ts index 82f98ba..630da20 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -64,6 +64,8 @@ export const registeredToolNames = [ 'kt_global_code_review', 'kt_finish_task', 'kt_workstream_closeout', + 'kt_workflow_loop_audit', + 'kt_change_doc_sync', 'kt_commit_plan', 'kt_push_plan', 'kt_business_test_plan', @@ -71,6 +73,9 @@ export const registeredToolNames = [ 'kt_remote_health_check', 'kt_db_sync_plan', 'kt_component_workflow', + 'kt_obsidian_context', + 'kt_obsidian_validate', + 'kt_obsidian_sync', 'kt_append_task_record', 'kt_commit_checklist', ] as const; @@ -82,4 +87,7 @@ export const defaultHistoryRoots = [ '.kt-workspace/playwright-mcp', '.kt-workspace/db-sync', '.kt-workspace/tmp', + '.kt-workspace/logs', + '.kt-workspace/deploy', + '.kt-workspace', ] as const; diff --git a/src/registerTools.ts b/src/registerTools.ts index de4f192..68a3f41 100644 --- a/src/registerTools.ts +++ b/src/registerTools.ts @@ -8,11 +8,14 @@ import { createCleanupProcessPlan, cleanupHistoryArtifacts } from './tools/clean import { buildEnvPolicy, scanTaskRisk } from './tools/envRisk.js'; import { buildGuardrails } from './tools/guardrails.js'; import { inspectAllProjects, inspectProject } from './tools/inspect.js'; +import { buildWorkflowLoopAudit } from './tools/loop.js'; +import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.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 { buildWorkstreamCloseout } from './tools/closeout.js'; +import { buildChangeDocSync } from './tools/docSync.js'; import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js'; import { workstreamReusablePatternValues } from './core/constants.js'; export function response(data: unknown): McpTextResponse { @@ -156,7 +159,7 @@ export function registerTools(server: McpServer): void { server.registerTool( 'kt_cleanup_history', { - description: '清理 KT 运行态历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个 .kt-workspace 历史目录仅保留最近 3 轮,.kt-workspace/test-artifacts/_templates 永久保留。', + description: '清理 KT 运行态历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个 .kt-workspace 历史目录仅保留最近 3 轮,template/_templates 等模板目录永久保留。', inputSchema: { dryRun: z.boolean().default(true), keep: z.number().int().min(0).max(50).default(3), @@ -259,6 +262,46 @@ export function registerTools(server: McpServer): void { async (input) => response(buildWorkstreamCloseout(input)), ); + server.registerTool( + 'kt_workflow_loop_audit', + { + description: '审计 KT 自动化闭环:上下文锚定、测试用例、验证证据、历史清理、问题固化、ktWorkflow 升级和全局 review 是否齐备。', + inputSchema: { + changedProjects: z.array(z.string()).default([]), + cleanupExecuted: z.boolean().default(false), + cleanupPreviewDeleted: z.number().int().min(0).default(0), + docSyncEvidence: z.array(z.string()).default([]), + failureRecords: z.array(z.string()).default([]), + ktWorkflowUpdated: z.boolean().default(false), + problemRecords: z.array(z.string()).default([]), + requireCompletion: z.boolean().default(true), + reusablePattern: z.boolean().default(false), + reviewEvidence: z.array(z.string()).default([]), + stableSolutions: z.array(z.string()).default([]), + stage: z.enum(['failure', 'finish', 'start', 'test']).default('finish'), + taskTitle: z.string().optional(), + testCases: z.array(z.string()).default([]), + verificationEvidence: z.array(z.string()).default([]), + }, + title: 'KT Workflow Loop Audit', + }, + async (input) => response(buildWorkflowLoopAudit(input)), + ); + + server.registerTool( + 'kt_change_doc_sync', + { + description: '根据代码改动路径生成必须检查/同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 文档入口。', + inputSchema: { + changedFiles: z.array(z.string()).default([]), + project: z.string().default('root'), + taskType: z.enum(taskTypeValues).optional(), + }, + title: 'KT Change Doc Sync', + }, + async (input) => response(await buildChangeDocSync(input)), + ); + server.registerTool( 'kt_commit_plan', { @@ -387,6 +430,53 @@ export function registerTools(server: McpServer): void { async (input) => response(buildComponentWorkflow(input)), ); + server.registerTool( + 'kt_obsidian_context', + { + description: '读取 KT Obsidian 索引上下文:总入口、模块索引、文档矩阵、工作流关系,并可按 module/query 定向返回相关模块页摘要。', + inputSchema: { + includeCanvas: z.boolean().default(true), + includeContent: z.boolean().default(true), + maxDocuments: z.number().int().min(1).max(30).default(8), + module: z.string().optional(), + query: z.string().optional(), + }, + title: 'KT Obsidian Context', + }, + async (input) => response(readObsidianContext(input)), + ); + + server.registerTool( + 'kt_obsidian_validate', + { + description: '只读校验 KT Obsidian vault:JSON、Canvas、Base、Wiki 链接、Markdown 相对链接和旧引用。', + inputSchema: { + checkLayout: z.boolean().default(true), + failOnWarnings: z.boolean().default(false), + includeMarkdownLinks: z.boolean().default(true), + includeStaleReferences: z.boolean().default(true), + }, + title: 'KT Obsidian Validate', + }, + async (input) => response(validateObsidianVault(input)), + ); + + server.registerTool( + 'kt_obsidian_sync', + { + description: '审计 KT Obsidian 工作流入口是否连通:必备文档、书签、核心插件、忽略目录和校验结果。', + inputSchema: { + checkLayout: z.boolean().default(true), + failOnWarnings: z.boolean().default(false), + includeMarkdownLinks: z.boolean().default(true), + includeStaleReferences: z.boolean().default(true), + includeValidation: z.boolean().default(true), + }, + title: 'KT Obsidian Sync', + }, + async (input) => response(syncObsidianWorkflow(input)), + ); + server.registerTool( 'kt_append_task_record', { diff --git a/src/selfTest.ts b/src/selfTest.ts index 530cc9c..a32e8c6 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -4,9 +4,12 @@ import path from 'node:path'; import { parseGlobalReviewCliArgs } from './core/cli.js'; import { registeredToolNames } from './core/constants.js'; import { buildBlockerResolution } from './tools/blocker.js'; +import { buildChangeDocSync } from './tools/docSync.js'; import { resolveInsideRoot } from './core/workspace.js'; import { buildGuardrails } from './tools/guardrails.js'; -import { cleanupHistoryArtifacts } from './tools/cleanup.js'; +import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js'; +import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js'; +import { buildWorkflowLoopAudit } from './tools/loop.js'; import { inspectProject } from './tools/inspect.js'; import { prepareTask, readWorkflowContext } from './tools/task.js'; import { buildGlobalCodeReview, isBenignCredentialReviewValue } from './tools/review.js'; @@ -71,6 +74,84 @@ export async function runSelfTest(): Promise { throw new Error('workstream closeout complete self-check failed'); } + const obsidianContext = readObsidianContext({ + maxDocuments: 6, + module: 'ktWorkflow', + }); + if ((obsidianContext.summary as { returnedDocuments?: number }).returnedDocuments === 0) { + throw new Error('obsidian context self-check failed'); + } + if ((obsidianContext.summary as { codeAnchors?: number }).codeAnchors === 0) { + throw new Error('obsidian code anchor self-check failed'); + } + + const obsidianValidation = validateObsidianVault({ + includeStaleReferences: false, + }); + if ((obsidianValidation.summary as { errors?: number }).errors !== 0) { + throw new Error('obsidian validation self-check failed'); + } + + const cleanupCliParser = { + defaultDryRun: parseCliCleanupArgs(['node', 'server', '--cleanup-history']).dryRun, + executeDryRun: parseCliCleanupArgs(['node', 'server', '--cleanup-history', '--execute']).dryRun, + }; + if (!cleanupCliParser.defaultDryRun || cleanupCliParser.executeDryRun) { + throw new Error('cleanup CLI dry-run parser self-check failed'); + } + + const cleanupPreview = cleanupHistoryArtifacts({ + dryRun: true, + keep: 3, + roots: ['.kt-workspace/test-artifacts', '.kt-workspace'], + }); + const cleanupRoots = cleanupPreview.roots as Array<{ + preservedNames: string[]; + root: string; + }>; + const testArtifactsRoot = cleanupRoots.find((item) => item.root === '.kt-workspace/test-artifacts'); + const workspaceHistoryRoot = cleanupRoots.find((item) => item.root === '.kt-workspace'); + if (!testArtifactsRoot?.preservedNames.includes('_templates')) { + throw new Error('cleanup template preservation self-check failed'); + } + if (!workspaceHistoryRoot?.preservedNames.includes('test-artifacts')) { + throw new Error('cleanup workspace root preservation self-check failed'); + } + + const docSyncBangDream = await buildChangeDocSync({ + changedFiles: ['src/qqbot/plugins/bangDream/application/bangdream-application.service.ts'], + project: 'api', + }); + const docSyncRequiredDocs = docSyncBangDream.requiredDocs as Array<{ path: string }>; + if (!docSyncRequiredDocs.some((item) => item.path === 'Node/kt-template-online-api/API.md')) { + throw new Error('doc sync API self-check failed'); + } + + const loopAuditComplete = buildWorkflowLoopAudit({ + cleanupExecuted: true, + cleanupPreviewDeleted: 3, + docSyncEvidence: ['kt_change_doc_sync requiredDocs 已处理。'], + ktWorkflowUpdated: true, + problemRecords: ['测试历史清理流程未自动执行。'], + reusablePattern: true, + reviewEvidence: ['global-review findings=0'], + stableSolutions: ['cleanup-history 默认 dry-run,显式 --execute 后复验 deleted=0。'], + taskTitle: '自动化闭环审计', + testCases: ['cleanup-history dry-run/execute 回归测试'], + verificationEvidence: ['typecheck/self-test/cleanup dry-run 通过'], + }); + if ((loopAuditComplete.canReportComplete as boolean) !== true) { + throw new Error('workflow loop complete self-check failed'); + } + + const loopAuditBlocked = buildWorkflowLoopAudit({ + cleanupPreviewDeleted: 1, + taskTitle: '缺证据闭环', + }); + if ((loopAuditBlocked.canReportComplete as boolean) !== false) { + throw new Error('workflow loop blocked self-check failed'); + } + const data = { context: readWorkflowContext({ taskRecordCount: 2 }), guardrails: buildGuardrails({ @@ -78,6 +159,11 @@ export async function runSelfTest(): Promise { taskType: 'mcp', userRequest: '扩展 KT 工作区 MCP 可复用能力', }), + cleanupCliParser, + cleanupPreview, + docSyncBangDream, + loopAuditBlocked, + loopAuditComplete, historyCleanup: cleanupHistoryArtifacts({ dryRun: true, keep: 3, @@ -104,6 +190,11 @@ export async function runSelfTest(): Promise { }), closeoutComplete, closeoutNeedsUpgrade, + obsidianContext, + obsidianSync: syncObsidianWorkflow({ + includeStaleReferences: false, + }), + obsidianValidation, blockerResolution: buildBlockerResolution({ attempts: 2, evidence: 'self-test', diff --git a/src/server.ts b/src/server.ts index acfa267..b1e6915 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,9 +2,16 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { parseGlobalReviewCliArgs, parseWorkstreamCloseoutCliArgs } from './core/cli.js'; +import { + parseGlobalReviewCliArgs, + parseObsidianContextCliArgs, + parseObsidianCliArgs, + parseObsidianSyncCliArgs, + parseWorkstreamCloseoutCliArgs, +} from './core/cli.js'; import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js'; import { buildWorkstreamCloseout } from './tools/closeout.js'; +import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js'; import { buildGlobalCodeReview } from './tools/review.js'; import { registerTools } from './registerTools.js'; import { runSelfTest } from './selfTest.js'; @@ -19,6 +26,12 @@ if (process.argv.includes('--cleanup-history')) { 2, ), ); +} else if (process.argv.includes('--obsidian-validate')) { + console.log(JSON.stringify(validateObsidianVault(parseObsidianCliArgs(process.argv)), null, 2)); +} else if (process.argv.includes('--obsidian-context')) { + console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2)); +} else if (process.argv.includes('--obsidian-sync')) { + console.log(JSON.stringify(syncObsidianWorkflow(parseObsidianSyncCliArgs(process.argv)), null, 2)); } else if (process.argv.includes('--self-test')) { await runSelfTest(); } else if (process.argv.includes('--workstream-closeout')) { diff --git a/src/tools/cleanup.ts b/src/tools/cleanup.ts index e31095b..fee548f 100644 --- a/src/tools/cleanup.ts +++ b/src/tools/cleanup.ts @@ -36,6 +36,41 @@ interface HistoryEntry { type: 'directory' | 'file'; } +const alwaysPreservedTemplateNames = new Set(['_template', '_templates', 'template', 'templates']); +const workspaceRootPreservedNames = new Set([ + 'backup', + 'db-sync', + 'deploy', + 'external', + 'logs', + 'playwright-mcp', + 'sources', + 'test-artifacts', + 'test-logs', + 'tmp', + 'verify', +]); + +function isTemplateEntryName(name: string): boolean { + return alwaysPreservedTemplateNames.has(name.toLowerCase()); +} + +function buildPreservedNames(rootRelativePath: string, childNames: string[]): Set { + const preservedNames = new Set(); + + for (const name of childNames) { + if (isTemplateEntryName(name)) preservedNames.add(name); + } + + if (rootRelativePath === '.kt-workspace') { + for (const name of workspaceRootPreservedNames) { + preservedNames.add(name); + } + } + + return preservedNames; +} + function listHistoryEntries(rootPath: string, preservedNames: Set): HistoryEntry[] { if (!existsSync(rootPath)) return []; @@ -91,9 +126,8 @@ export function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record continue; } - const preservedNames = new Set( - rootRelativePath.endsWith('test-artifacts') ? ['_templates'] : [], - ); + const childNames = readdirSync(rootPath, { withFileTypes: true }).map((item) => item.name); + const preservedNames = buildPreservedNames(rootRelativePath, childNames); const entries = listHistoryEntries(rootPath, preservedNames); const preserved = entries.slice(0, keep); const deleting = entries.slice(keep); @@ -127,7 +161,7 @@ export function parseCliCleanupArgs(args: string[]): CleanupHistoryInput { const rootsArg = args.find((arg) => arg.startsWith('--roots=')); return { - dryRun: args.includes('--dry-run'), + dryRun: !(args.includes('--execute') || args.includes('--no-dry-run')), keep: keepArg ? Number.parseInt(keepArg.split('=')[1], 10) : 3, roots: rootsArg ? rootsArg.split('=')[1].split(',').filter(Boolean) : [...defaultHistoryRoots], }; diff --git a/src/tools/docSync.ts b/src/tools/docSync.ts new file mode 100644 index 0000000..410310d --- /dev/null +++ b/src/tools/docSync.ts @@ -0,0 +1,139 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import type { ChangeDocSyncInput } from '../types.js'; +import { detectRepoType, parseGitStatusFiles } from '../core/project.js'; +import { tryExecFile } from '../core/exec.js'; +import { resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; + +interface DocSyncRule { + docs: string[]; + match: RegExp; + reason: string; +} + +const docSyncRules: DocSyncRule[] = [ + { + docs: ['Node/kt-template-online-api/README.md', 'Node/kt-template-online-api/API.md', 'docs/obsidian/modules/KT 模块 - API Backend.md'], + match: /^(src\/|Node\/kt-template-online-api\/src\/).*(controller|service|dto|entity|module|guard|interceptor|filter)|^Node\/kt-template-online-api\/src\//, + reason: 'API 接口、DTO/Entity、模块或服务能力变更,需要同步后端 README/API 和模块索引。', + }, + { + docs: ['Node/kt-template-online-api/API.md', 'docs/qqbot-nas-runtime.md', 'docs/obsidian/modules/KT 模块 - QQBot BangDream FFLogs.md'], + match: /qqbot|bangDream|fflogs|ff14Market|napcat/i, + reason: 'QQBot/BangDream/FFLogs/NapCat 链路变更,需要同步命令、运行态和 smoke 文档。', + }, + { + docs: ['Vue/kt-template-admin/README.md', 'docs/obsidian/modules/KT 模块 - Admin.md'], + match: /Vue\/kt-template-admin\/|apps\/web-antdv-next\/src\/views\/|apps\/web-antdv-next\/src\/api\//, + reason: 'Admin 页面、API 客户端或业务视图变更,需要同步 Admin README 和模块页。', + }, + { + docs: ['Vue/kt-template-admin/README.md', 'docs/obsidian/modules/KT 模块 - KtTable.md', 'skills/kt-component-specialist/SKILL.md'], + match: /ktTable|KtTable|components\/ktTable/i, + reason: 'KtTable 组件或迁移规则变更,需要同步专项模块和 skill。', + }, + { + docs: ['Vue/kt-blog-web/README.md', 'docs/obsidian/modules/KT 模块 - Blog Web.md', 'skills/kt-blog-argon-workflow/SKILL.md'], + match: /Vue\/kt-blog-web\/|blog|argon/i, + reason: 'Blog/Argon 能力变更,需要同步博客 README、模块页和专项 skill。', + }, + { + docs: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'docs/obsidian/modules/KT 模块 - Plugins.md'], + match: /fnos-k8s-dashboard|fnosK8s|fpk|Kubernetes/i, + reason: 'fnOS K8s 插件变更,需要同步插件 README/AGENTS 和 Obsidian 模块。', + }, + { + docs: ['mcp/ktWorkflow/README.md', 'AGENTS.md', 'TASKS.md', 'docs/kt-testing-workflows.md', 'docs/obsidian/KT 工作流工程化.md'], + match: /mcp\/ktWorkflow|src\/tools\/|src\/registerTools|src\/core|workflow|cleanup|review|selfTest/i, + reason: 'ktWorkflow 或自动化规则变更,需要同步 README、根规则、测试流程和当前任务记录。', + }, + { + docs: ['AGENTS.md', 'SKILLS.md', 'docs/kt-context-semantics.md', 'docs/obsidian/modules/KT 模块 - Root Governance.md'], + match: /AGENTS\.md|SKILLS\.md|skills\/.*\/SKILL\.md|docs\/kt-context-semantics\.md/, + reason: '上下文治理或 skill 规则变更,需要同步根治理文档和 Obsidian 模块。', + }, + { + docs: ['docs/obsidian/KT 文档矩阵.md', 'docs/obsidian/KT Obsidian 工程化.md', 'docs/obsidian/KT 关系图谱.canvas'], + match: /\.obsidian|docs\/obsidian|\.canvas$|\.base$/, + reason: 'Obsidian vault、Canvas、Base 或文档矩阵变更,需要同步图谱和校验。', + }, + { + docs: ['docs/kt-testing-workflows.md', 'mcp/ktWorkflow/README.md'], + match: /\.kt-workspace|test-artifacts|test-logs|verify|playwright|smoke|Jenkinsfile|k8s\/|deploy\//i, + reason: '测试、验证、发布或运行态产物流程变更,需要同步测试流程和 ktWorkflow 文档。', + }, +]; + +async function inferChangedFiles(projectKey: string): Promise { + const project = resolveProject(projectKey); + if (detectRepoType(project.path) !== 'git') return []; + + const status = await tryExecFile('git', ['status', '--short'], project.path); + if (!status.ok) return []; + + return parseGitStatusFiles(status.stdout).map((file) => + project.alias && project.alias !== 'root' + ? `${project.relativePath}/${file.replaceAll('\\', '/')}` + : file.replaceAll('\\', '/'), + ); +} + +function normalizeChangedFile(file: string, projectKey?: string): string { + const project = resolveProject(projectKey || 'root'); + const normalized = file.replaceAll('\\', '/'); + if (project.alias && project.alias !== 'root' && !normalized.startsWith(`${project.relativePath}/`)) { + return `${project.relativePath}/${normalized}`; + } + return normalized; +} + +export async function buildChangeDocSync(input: ChangeDocSyncInput = {}): Promise> { + const projectKey = input.project || 'root'; + const changedFiles = (input.changedFiles?.length ?? 0) > 0 + ? input.changedFiles!.map((file) => normalizeChangedFile(file, projectKey)) + : await inferChangedFiles(projectKey); + const recommendations = docSyncRules + .map((rule) => { + const matchedFiles = changedFiles.filter((file) => rule.match.test(file)); + return matchedFiles.length > 0 + ? { + docs: rule.docs.map((file) => ({ + exists: existsSync(path.resolve(workspaceRoot, file)), + path: file, + })), + matchedFiles, + reason: rule.reason, + } + : null; + }) + .filter((item): item is NonNullable => Boolean(item)); + const requiredDocs = Array.from( + new Set(recommendations.flatMap((item) => item.docs.map((doc) => doc.path))), + ).map((file) => ({ + exists: existsSync(path.resolve(workspaceRoot, file)), + path: file, + })); + + return { + changedFiles, + commands: [ + 'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query ', + 'pnpm --dir mcp/ktWorkflow run obsidian-validate', + 'pnpm --dir mcp/ktWorkflow run global-review', + ], + requiredDocs, + recommendations, + summary: { + changedFileCount: changedFiles.length, + recommendationCount: recommendations.length, + requiredDocCount: requiredDocs.length, + }, + verification: [ + '确认 requiredDocs 中需要更新的文档已同步,未更新时在最终说明原因。', + '涉及 Obsidian 索引时运行 obsidian-validate。', + '涉及 ktWorkflow/规则/测试流程时运行 self-test 和 global-review。', + `当前项目:${toPosix(resolveProject(projectKey).relativePath)}`, + ], + }; +} diff --git a/src/tools/loop.ts b/src/tools/loop.ts new file mode 100644 index 0000000..cec7feb --- /dev/null +++ b/src/tools/loop.ts @@ -0,0 +1,150 @@ +import type { WorkflowLoopAuditInput } from '../types.js'; + +interface LoopGate { + evidence: string[]; + name: string; + required: boolean; + status: 'missing' | 'not-needed' | 'ok'; + suggestion?: string; +} + +function normalizeList(values?: string[]): string[] { + return (values || []).map((value) => value.trim()).filter(Boolean); +} + +function buildRequiredGate(name: string, evidence: string[], suggestion: string): LoopGate { + return { + evidence, + name, + required: true, + status: evidence.length > 0 ? 'ok' : 'missing', + suggestion: evidence.length > 0 ? undefined : suggestion, + }; +} + +function buildBooleanGate(name: string, ok: boolean, evidence: string[], suggestion: string): LoopGate { + return { + evidence, + name, + required: true, + status: ok ? 'ok' : 'missing', + suggestion: ok ? undefined : suggestion, + }; +} + +export function buildWorkflowLoopAudit(input: WorkflowLoopAuditInput = {}): Record { + const stage = input.stage || 'finish'; + const requireCompletion = input.requireCompletion !== false && stage === 'finish'; + const changedProjects = normalizeList(input.changedProjects); + const testCases = normalizeList(input.testCases); + const verificationEvidence = normalizeList(input.verificationEvidence); + const failureRecords = normalizeList(input.failureRecords); + const docSyncEvidence = normalizeList(input.docSyncEvidence); + const problemRecords = normalizeList(input.problemRecords); + const stableSolutions = normalizeList(input.stableSolutions); + const reviewEvidence = normalizeList(input.reviewEvidence); + const cleanupPreviewDeleted = Math.max(0, input.cleanupPreviewDeleted ?? 0); + const cleanupOk = cleanupPreviewDeleted === 0 || input.cleanupExecuted === true; + const hasFailure = failureRecords.length > 0; + const reusablePattern = input.reusablePattern === true; + const needsSolidification = hasFailure || reusablePattern; + const ktWorkflowUpdated = input.ktWorkflowUpdated === true; + + const gates: LoopGate[] = [ + buildRequiredGate('task target', [input.taskTitle || ''].filter(Boolean), '写清本轮任务目标。'), + buildRequiredGate('test case before test', testCases, '测试前先生成页面/业务/接口测试用例或验证计划。'), + buildRequiredGate('verification evidence', verificationEvidence, '补充真实验证证据;只有计划不算完成。'), + buildBooleanGate( + 'history cleanup', + cleanupOk, + cleanupOk + ? [ + cleanupPreviewDeleted === 0 + ? 'cleanup-history dry-run deleted=0' + : 'cleanup-history executed after stale artifacts were found', + ] + : [`cleanup-history dry-run deleted=${cleanupPreviewDeleted}`], + '执行 kt_cleanup_history dryRun=false 或 pnpm run cleanup-history -- --execute,并复跑 dry-run 确认 deleted=0。', + ), + buildRequiredGate('documentation sync evidence', docSyncEvidence, '运行 kt_change_doc_sync 并同步 README/API/AGENTS/docs/Obsidian/skills/ktWorkflow 中需要更新的入口;无需更新时写明原因。'), + buildRequiredGate('global review evidence', reviewEvidence, '文件改动后跑 kt_global_code_review 或 pnpm run global-review。'), + ]; + + if (needsSolidification) { + gates.push( + buildRequiredGate('problem record', problemRecords, '写入问题点,至少包含触发条件和证据。'), + buildRequiredGate('stable solution', stableSolutions, '写入稳定解法和后续入口。'), + buildBooleanGate( + 'ktWorkflow upgrade', + ktWorkflowUpdated, + ktWorkflowUpdated ? ['ktWorkflow updated or audited as not needing executable upgrade'] : [], + '可复用问题必须沉淀到 AGENTS.md、skills、docs 或 mcp/ktWorkflow;能自动化的要升级 ktWorkflow。', + ), + ); + } else { + gates.push({ + evidence: ['无失败或可复用问题。'], + name: 'problem solidification', + required: false, + status: 'not-needed', + }); + } + + const missingItems = requireCompletion + ? gates + .filter((gate) => gate.required && gate.status === 'missing') + .map((gate) => `${gate.name}: ${gate.suggestion}`) + : []; + + return { + canReportComplete: missingItems.length === 0, + commands: [ + 'pnpm --dir mcp/ktWorkflow run obsidian-context -- --query ', + 'pnpm --dir mcp/ktWorkflow run self-test', + 'pnpm --dir mcp/ktWorkflow run cleanup-history -- --dry-run', + 'pnpm --dir mcp/ktWorkflow run cleanup-history -- --execute', + 'pnpm --dir mcp/ktWorkflow run global-review', + 'pnpm --dir mcp/ktWorkflow run workstream-closeout -- --title "<目标>" --verification "<证据>" --problem "<问题记录>" --solution "<稳定解法>"', + ], + gates, + loops: { + contextLoop: [ + '读取 AGENTS.md / SKILLS.md / TASKS.md。', + '用 kt_obsidian_context 获取 ruleAnchors 和 codeAnchors。', + '用 kt_inspect_project / kt_risk_scan / kt_suggest_verification 锚定代码上下文。', + ], + problemSolidificationLoop: [ + '失败或主动修复后,先判断是否可复用。', + '可复用则记录问题点 / 稳定解法 / 后续入口 / 验证证据。', + '能自动化的升级 mcp/ktWorkflow;规则类写 AGENTS.md;长流程写 docs;当前证据写 TASKS.md。', + '复跑 self-test / 相关脚本 / global-review。', + ], + documentationSyncLoop: [ + '收集 changedFiles。', + '运行 kt_change_doc_sync 生成 requiredDocs。', + '更新必要的 README/API/AGENTS/SKILLS/docs/Obsidian/skill/ktWorkflow 入口。', + '无法或无需更新时写明原因,并保留在 TASKS.md 或最终报告。', + ], + testLoop: [ + '测试前先写用例或验证计划。', + '按用例执行验证并保存证据到 .kt-workspace。', + '失败最多三轮,每轮必须改变一个可验证变量。', + '每轮结束运行 cleanup-history dry-run;存在待删项则执行 --execute 并复验 deleted=0。', + ], + }, + missingItems, + stage, + summary: { + changedProjects, + cleanupPreviewDeleted, + docSyncEvidenceCount: docSyncEvidence.length, + failureCount: failureRecords.length, + problemRecordCount: problemRecords.length, + reusablePattern, + reviewEvidenceCount: reviewEvidence.length, + stableSolutionCount: stableSolutions.length, + testCaseCount: testCases.length, + verificationEvidenceCount: verificationEvidence.length, + }, + }; +} diff --git a/src/tools/obsidian.ts b/src/tools/obsidian.ts new file mode 100644 index 0000000..90d1658 --- /dev/null +++ b/src/tools/obsidian.ts @@ -0,0 +1,1055 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { ObsidianContextInput, ObsidianSyncInput, ObsidianValidateInput, ProjectAlias } from '../types.js'; +import { projectAliases, projectLabels } from '../core/constants.js'; +import { detectRepoType } from '../core/project.js'; +import { resolveInsideRoot, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; + +type FindingLevel = 'error' | 'info' | 'warning'; + +interface ObsidianFinding { + check: string; + level: FindingLevel; + message: string; + path?: string; + target?: string; +} + +interface CanvasNode { + color?: string; + file?: string; + height?: number; + id?: string; + type?: string; + width?: number; + x?: number; + y?: number; +} + +interface CanvasEdge { + fromNode?: string; + id?: string; + toNode?: string; +} + +interface CanvasDocument { + edges?: CanvasEdge[]; + nodes?: CanvasNode[]; +} + +interface ObsidianSettings { + attachmentFolderPath?: string; + userIgnoreFilters?: string[]; +} + +interface BookmarkItem { + path?: string; + type?: string; +} + +interface BookmarkSettings { + items?: BookmarkItem[]; +} + +interface ObsidianContextDocument { + excerpt?: string; + links: string[]; + path: string; + tags: string[]; + title: string; +} + +interface CodeAnchorCatalogItem { + alias: ProjectAlias; + keyFiles: string[]; + keywords: string[]; + ruleFiles: string[]; + sourceRoots: string[]; +} + +interface CodeAnchorResult { + alias: ProjectAlias; + exists: boolean; + inspectTool: { + input: { + includeGit: boolean; + project: ProjectAlias; + }; + name: 'kt_inspect_project'; + }; + keyFiles: Array<{ + exists: boolean; + path: string; + }>; + label: string; + projectPath: string; + repoType: string; + riskTool: { + input: { + changedFiles: string[]; + project: ProjectAlias; + }; + name: 'kt_risk_scan'; + }; + score: number; + ruleFiles: Array<{ + exists: boolean; + path: string; + }>; + sourceRoots: Array<{ + exists: boolean; + path: string; + }>; + verificationTool: { + input: { + changeType: 'general' | 'mcp'; + project: ProjectAlias; + }; + name: 'kt_suggest_verification'; + }; +} + +const obsidianConfigFiles = [ + '.obsidian/app.json', + '.obsidian/appearance.json', + '.obsidian/bookmarks.json', + '.obsidian/community-plugins.json', + '.obsidian/core-plugins.json', + '.obsidian/graph.json', + '.obsidian/templates.json', +]; + +const expectedObsidianFiles = [ + 'docs/obsidian/KT 知识图谱.md', + 'docs/obsidian/KT 项目关系.md', + 'docs/obsidian/KT 业务能力关系.md', + 'docs/obsidian/KT 发布运维关系.md', + 'docs/obsidian/KT 工作流关系.md', + 'docs/obsidian/KT Obsidian 工程化.md', + 'docs/obsidian/KT 工作流工程化.md', + 'docs/obsidian/KT 文档矩阵.md', + 'docs/obsidian/KT 文档矩阵.base', + 'docs/obsidian/KT 关系图谱.canvas', + 'docs/obsidian/modules/KT 模块索引.md', + 'docs/obsidian/modules/KT 模块 - Admin.md', + 'docs/obsidian/modules/KT 模块 - API Backend.md', + 'docs/obsidian/modules/KT 模块 - Blog Web.md', + 'docs/obsidian/modules/KT 模块 - CI CD NAS.md', + 'docs/obsidian/modules/KT 模块 - Data Observability.md', + 'docs/obsidian/modules/KT 模块 - KtTable.md', + 'docs/obsidian/modules/KT 模块 - Plugins.md', + 'docs/obsidian/modules/KT 模块 - Public Web Playground.md', + 'docs/obsidian/modules/KT 模块 - QQBot BangDream FFLogs.md', + 'docs/obsidian/modules/KT 模块 - Root Governance.md', + 'docs/obsidian/modules/KT 模块 - ktWorkflow Skills.md', +]; + +const requiredBookmarkPaths = [ + 'AGENTS.md', + 'TASKS.md', + 'SKILLS.md', + 'docs/obsidian/KT 知识图谱.md', + 'docs/obsidian/modules/KT 模块索引.md', + 'docs/obsidian/KT 文档矩阵.md', + 'docs/obsidian/KT 关系图谱.canvas', + 'docs/obsidian/KT 文档矩阵.base', + 'docs/kt-testing-workflows.md', +]; + +const requiredCorePlugins = [ + 'backlink', + 'bases', + 'bookmarks', + 'canvas', + 'command-palette', + 'global-search', + 'graph', + 'outgoing-link', + 'templates', +]; + +const expectedIgnoredRoots = [ + '.git/', + '.kt-workspace/', + '_exports/', + 'Node/', + 'Plugins/', + 'Vue/', + 'mcp/', +]; + +const defaultContextPaths = [ + 'docs/obsidian/KT 知识图谱.md', + 'docs/obsidian/modules/KT 模块索引.md', + 'docs/obsidian/KT 文档矩阵.md', + 'docs/obsidian/KT 工作流关系.md', +]; + +const codeAnchorCatalog: CodeAnchorCatalogItem[] = [ + { + alias: 'root', + keyFiles: ['AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-workspace-structure.md', 'docs/kt-context-semantics.md'], + keywords: ['root', 'governance', 'agents', 'skills', 'tasks', 'obsidian', '上下文', '根目录', '治理', '图谱'], + ruleFiles: ['AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-context-semantics.md', 'docs/kt-workspace-structure.md'], + sourceRoots: ['docs', 'skills', 'ci'], + }, + { + alias: 'mcp', + keyFiles: ['mcp/ktWorkflow/README.md', 'mcp/ktWorkflow/src/server.ts', 'mcp/ktWorkflow/src/registerTools.ts', 'mcp/ktWorkflow/src/tools'], + keywords: ['mcp', 'ktworkflow', 'workflow', 'skill', 'tools', '工作流', '校验', '上下文工具'], + ruleFiles: ['AGENTS.md', 'SKILLS.md', 'TASKS.md', 'docs/kt-testing-workflows.md', 'mcp/ktWorkflow/README.md'], + sourceRoots: ['mcp/ktWorkflow/src', 'mcp/ktWorkflow/scripts'], + }, + { + alias: 'api', + keyFiles: ['Node/kt-template-online-api/README.md', 'Node/kt-template-online-api/API.md', 'Node/kt-template-online-api/src/qqbot', 'Node/kt-template-online-api/src/admin'], + keywords: ['api', 'backend', 'nestjs', 'qqbot', 'bangdream', 'fflogs', 'napcat', 'systemlog', '后端', '接口', '日志'], + ruleFiles: ['AGENTS.md', 'Node/kt-template-online-api/README.md', 'Node/kt-template-online-api/API.md', 'docs/kt-testing-workflows.md', 'docs/qqbot-nas-runtime.md'], + sourceRoots: ['Node/kt-template-online-api/src', 'Node/kt-template-online-api/test'], + }, + { + alias: 'admin', + keyFiles: ['Vue/kt-template-admin/README.md', 'Vue/kt-template-admin/skills/SKILL.md', 'Vue/kt-template-admin/apps/web-antdv-next/src'], + keywords: ['admin', 'vben', 'kttable', 'profile', 'system', '后台', '表格', '个人中心', '系统管理'], + ruleFiles: ['AGENTS.md', 'Vue/kt-template-admin/README.md', 'Vue/kt-template-admin/skills/SKILL.md', 'docs/kt-testing-workflows.md'], + sourceRoots: ['Vue/kt-template-admin/apps/web-antdv-next/src'], + }, + { + alias: 'blog', + keyFiles: ['Vue/kt-blog-web/README.md', 'Vue/kt-blog-web/src'], + keywords: ['blog', 'argon', 'wordpress', '博客', '主题'], + ruleFiles: ['AGENTS.md', 'Vue/kt-blog-web/README.md', 'skills/kt-component-specialist/SKILL.md', 'docs/kt-testing-workflows.md'], + sourceRoots: ['Vue/kt-blog-web/src'], + }, + { + alias: 'web', + keyFiles: ['Vue/kt-template-online-web/README.md', 'Vue/kt-template-online-web/src'], + keywords: ['web', 'public', 'online', '前台', '展示'], + ruleFiles: ['AGENTS.md', 'Vue/kt-template-online-web/README.md', 'docs/kt-testing-workflows.md'], + sourceRoots: ['Vue/kt-template-online-web/src'], + }, + { + alias: 'playground', + keyFiles: ['Vue/kt-template-online-playground/README.md', 'Vue/kt-template-online-playground/src'], + keywords: ['playground', 'repl', 'component', '组件', '预览', '保存'], + ruleFiles: ['AGENTS.md', 'Vue/kt-template-online-playground/README.md', 'docs/kt-testing-workflows.md'], + sourceRoots: ['Vue/kt-template-online-playground/src'], + }, + { + alias: 'knife4j', + keyFiles: ['Plugins/knife4j-swagger-vue3/README.md', 'Plugins/knife4j-swagger-vue3/src'], + keywords: ['knife4j', 'swagger', 'openapi', '插件'], + ruleFiles: ['AGENTS.md', 'Plugins/knife4j-swagger-vue3/AGENTS.md', 'Plugins/knife4j-swagger-vue3/README.md', 'docs/kt-testing-workflows.md'], + sourceRoots: ['Plugins/knife4j-swagger-vue3/src'], + }, + { + alias: 'fnosK8s', + keyFiles: ['Plugins/fnos-k8s-dashboard-fpk/README.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'Plugins/fnos-k8s-dashboard-fpk/apps/kt-k8s-dashboard'], + keywords: ['fnos', 'k8s', 'kubernetes', 'fpk', 'nas', 'jenkins', 'caddy', '飞牛', '插件', '部署'], + ruleFiles: ['AGENTS.md', 'Plugins/fnos-k8s-dashboard-fpk/AGENTS.md', 'Plugins/fnos-k8s-dashboard-fpk/README.md', 'docs/fnos-docker-jenkins-k8s-standard.md', 'docs/tencent-cloud-gateway-runtime.md'], + sourceRoots: ['Plugins/fnos-k8s-dashboard-fpk/apps/kt-k8s-dashboard'], + }, +]; + +const staleReferencePatterns = [ + 'docs/next-session-context-019e2f8f.md', + 'next-session-context-019e2f8f', + 'docs/fnos-docker-jenkins-k8s-technical-solution.md', + 'fnos-docker-jenkins-k8s-technical-solution', + 'yd.frp-bag.com', + '45122', +]; + +const allowedStaleReferenceHints = [ + 'Do not use stale historical', + '不要使用旧入口', + '旧 FRP', + 'historical FRP', + '历史 FRP', + '旧文档清理', +]; + +const ignoredDirectoryNames = new Set([ + '.cache', + '.git', + '.kt-workspace', + '.next', + '.pnpm', + '.turbo', + '.vite', + '_exports', + 'build', + 'coverage', + 'dist', + 'node_modules', + 'target', +]); + +function normalizeInput(input: ObsidianValidateInput = {}): Required { + return { + checkLayout: input.checkLayout !== false, + failOnWarnings: input.failOnWarnings === true, + includeMarkdownLinks: input.includeMarkdownLinks !== false, + includeStaleReferences: input.includeStaleReferences !== false, + }; +} + +function readTextFile(relativePath: string): string { + return readFileSync(resolveInsideRoot(relativePath), 'utf8'); +} + +function getMarkdownTitle(content: string, fallback: string): string { + return ( + content.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') || + content.match(/^#\s+(.+)$/m)?.[1]?.trim() || + path.basename(fallback, path.extname(fallback)) + ); +} + +function getMarkdownTags(content: string): string[] { + const frontmatter = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatter) return []; + + const tagsBlock = frontmatter[1]?.match(/^tags:\n((?:\s+-\s+.+\n?)+)/m)?.[1] || ''; + return tagsBlock + .split(/\r?\n/) + .map((line) => line.match(/^\s+-\s+(.+)$/)?.[1]?.trim()) + .filter((tag): tag is string => Boolean(tag)); +} + +function extractWikiLinks(content: string): string[] { + return [...content.matchAll(/\[\[([^\]\n]+)\]\]/g)] + .map((match) => match[1]?.split('|')[0]?.split('#')[0]?.trim()) + .filter((value): value is string => Boolean(value)); +} + +function normalizeSearchValue(value: string): string { + return value.trim().toLowerCase(); +} + +function buildExcerpt(content: string, terms: string[]): string { + const body = content.replace(/^---\n[\s\S]*?\n---\n?/, ''); + const lines = body + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('---')); + const matchedLine = lines.find((line) => { + const loweredLine = normalizeSearchValue(line); + return terms.some((term) => loweredLine.includes(term)); + }); + return (matchedLine || lines[0] || '').slice(0, 220); +} + +function readContextDocument(relativePath: string, includeContent: boolean, terms: string[] = []): ObsidianContextDocument { + const content = readTextFile(relativePath); + const document: ObsidianContextDocument = { + links: extractWikiLinks(content), + path: relativePath, + tags: getMarkdownTags(content), + title: getMarkdownTitle(content, relativePath), + }; + + if (includeContent) { + document.excerpt = buildExcerpt(content, terms); + } + + return document; +} + +function listObsidianMarkdownPaths(): string[] { + return [ + ...listFiles(resolveInsideRoot('docs/obsidian'), new Set(['.md'])).map(relativeToWorkspace), + 'AGENTS.md', + 'SKILLS.md', + 'TASKS.md', + ].filter((file) => existsSync(resolveInsideRoot(file))); +} + +function scoreContextPath(relativePath: string, terms: string[]): number { + if (terms.length === 0) return defaultContextPaths.includes(relativePath) ? 10 : 0; + + const content = readTextFile(relativePath); + const haystack = normalizeSearchValue(`${relativePath}\n${content}`); + return terms.reduce((score, term) => score + (haystack.includes(term) ? 1 : 0), 0); +} + +function buildContextTerms(input: ObsidianContextInput): string[] { + return [input.module, input.query] + .filter((value): value is string => Boolean(value?.trim())) + .flatMap((value) => value.split(/[,\s]+/)) + .map(normalizeSearchValue) + .filter(Boolean); +} + +function readCanvasSummary(includeCanvas: boolean | undefined): Record | null { + if (includeCanvas === false) return null; + + const canvas = readJsonFile('docs/obsidian/KT 关系图谱.canvas', []); + if (!canvas) return null; + + const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : []; + const fileNodes = nodes + .filter((node) => node.type === 'file' && node.file) + .map((node) => ({ + color: node.color, + file: node.file, + id: node.id, + })); + + return { + edges: Array.isArray(canvas.edges) ? canvas.edges.length : 0, + fileNodes, + nodes: nodes.length, + }; +} + +function scoreCodeAnchor(anchor: CodeAnchorCatalogItem, terms: string[]): number { + if (terms.length === 0) return anchor.alias === 'root' || anchor.alias === 'mcp' ? 1 : 0; + + const haystack = normalizeSearchValue([ + anchor.alias, + projectLabels[anchor.alias], + ...anchor.keywords, + ...anchor.keyFiles, + ...anchor.sourceRoots, + ].join('\n')); + + return terms.reduce((score, term) => score + (haystack.includes(term) ? 1 : 0), 0); +} + +function buildFileAnchor(relativePath: string): { exists: boolean; path: string } { + return { + exists: existsSync(resolveInsideRoot(relativePath)), + path: relativePath, + }; +} + +function buildRuleAnchors(codeAnchors: CodeAnchorResult[]): Array<{ exists: boolean; path: string }> { + const rulePaths = new Set([ + 'AGENTS.md', + 'SKILLS.md', + 'TASKS.md', + 'docs/kt-testing-workflows.md', + ...codeAnchors.flatMap((anchor) => anchor.ruleFiles.map((file) => file.path)), + ]); + + return [...rulePaths].map(buildFileAnchor); +} + +function buildCodeAnchors(terms: string[], maxAnchors = 5): CodeAnchorResult[] { + return codeAnchorCatalog + .map((anchor) => ({ + anchor, + score: scoreCodeAnchor(anchor, terms), + })) + .filter((item) => item.score > 0) + .sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + return left.anchor.alias.localeCompare(right.anchor.alias); + }) + .slice(0, maxAnchors) + .map(({ anchor, score }) => { + const project = resolveProject(anchor.alias); + return { + alias: anchor.alias, + exists: existsSync(project.path), + inspectTool: { + input: { + includeGit: true, + project: anchor.alias, + }, + name: 'kt_inspect_project', + }, + keyFiles: anchor.keyFiles.map((file) => ({ + exists: existsSync(resolveInsideRoot(file)), + path: file, + })), + label: projectLabels[anchor.alias], + projectPath: project.relativePath, + repoType: existsSync(project.path) ? detectRepoType(project.path) : 'missing', + riskTool: { + input: { + changedFiles: [], + project: anchor.alias, + }, + name: 'kt_risk_scan', + }, + score, + ruleFiles: anchor.ruleFiles.map(buildFileAnchor), + sourceRoots: anchor.sourceRoots.map((file) => ({ + exists: existsSync(resolveInsideRoot(file)), + path: file, + })), + verificationTool: { + input: { + changeType: anchor.alias === 'mcp' ? 'mcp' : 'general', + project: anchor.alias, + }, + name: 'kt_suggest_verification', + }, + }; + }); +} + +function readJsonFile(relativePath: string, findings: ObsidianFinding[]): T | null { + if (!existsSync(resolveInsideRoot(relativePath))) { + findings.push({ + check: 'json', + level: 'error', + message: '文件不存在。', + path: relativePath, + }); + return null; + } + + try { + return JSON.parse(readTextFile(relativePath)) as T; + } catch (error) { + findings.push({ + check: 'json', + level: 'error', + message: error instanceof Error ? error.message : 'JSON 解析失败。', + path: relativePath, + }); + return null; + } +} + +function listFiles(absoluteRoot: string, extensions: Set, output: string[] = []): string[] { + if (!existsSync(absoluteRoot)) return output; + + for (const entry of readdirSync(absoluteRoot, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!ignoredDirectoryNames.has(entry.name)) { + listFiles(path.join(absoluteRoot, entry.name), extensions, output); + } + continue; + } + + if (entry.isFile() && extensions.has(path.extname(entry.name).toLowerCase())) { + output.push(path.join(absoluteRoot, entry.name)); + } + } + + return output; +} + +function relativeToWorkspace(filePath: string): string { + return toPosix(path.relative(workspaceRoot, filePath)); +} + +function stripKnownExtension(value: string): string { + return value.replace(/\.(base|canvas|md)$/i, ''); +} + +function buildFileIndex(): { basename: Map; full: Set } { + const files = listFiles(workspaceRoot, new Set(['.base', '.canvas', '.md'])); + const full = new Set(); + const basename = new Map(); + + for (const file of files) { + const relativePath = relativeToWorkspace(file); + const fullKey = stripKnownExtension(relativePath); + const baseKey = stripKnownExtension(path.basename(relativePath)); + full.add(fullKey); + full.add(relativePath); + basename.set(baseKey, [...(basename.get(baseKey) || []), relativePath]); + } + + return { basename, full }; +} + +function isWikiLinkResolved(target: string, fileIndex: ReturnType): boolean { + const normalizedTarget = stripKnownExtension(target.replaceAll('\\', '/')); + if (fileIndex.full.has(normalizedTarget) || fileIndex.full.has(target)) return true; + + if (normalizedTarget.includes('/')) return false; + + return (fileIndex.basename.get(normalizedTarget) || []).length === 1; +} + +function getObsidianMarkdownFiles(): string[] { + return [ + ...listFiles(resolveInsideRoot('docs/obsidian'), new Set(['.md'])), + ...listFiles(resolveInsideRoot('.obsidian/templates'), new Set(['.md'])), + ]; +} + +function validateWikiLinks(findings: ObsidianFinding[]): number { + const fileIndex = buildFileIndex(); + let linkCount = 0; + + for (const absolutePath of getObsidianMarkdownFiles()) { + const relativePath = relativeToWorkspace(absolutePath); + const content = readFileSync(absolutePath, 'utf8'); + const matches = content.matchAll(/\[\[([^\]\n]+)\]\]/g); + + for (const match of matches) { + linkCount += 1; + const rawTarget = match[1]?.split('|')[0]?.split('#')[0]?.trim() || ''; + if (!rawTarget) continue; + + if (!isWikiLinkResolved(rawTarget, fileIndex)) { + findings.push({ + check: 'wikilink', + level: 'error', + message: '内部链接无法解析到工作区文件。', + path: relativePath, + target: rawTarget, + }); + } + } + } + + return linkCount; +} + +function isExternalMarkdownTarget(target: string): boolean { + return /^(https?:|mailto:|data:|obsidian:)/i.test(target); +} + +function decodeLinkTarget(target: string): string { + try { + return decodeURIComponent(target); + } catch { + return target; + } +} + +function validateMarkdownLinks(findings: ObsidianFinding[]): number { + let linkCount = 0; + + for (const absolutePath of getObsidianMarkdownFiles()) { + const relativePath = relativeToWorkspace(absolutePath); + const content = readFileSync(absolutePath, 'utf8'); + const matches = content.matchAll(/!?\[[^\]\n]*\]\(([^)\n]+)\)/g); + + for (const match of matches) { + const rawTarget = match[1]?.trim().replace(/^<|>$/g, '') || ''; + const targetWithoutAnchor = decodeLinkTarget(rawTarget.split('#')[0]?.trim() || ''); + if (!targetWithoutAnchor || targetWithoutAnchor.startsWith('#') || isExternalMarkdownTarget(targetWithoutAnchor)) { + continue; + } + + linkCount += 1; + const absoluteTarget = path.resolve(path.dirname(absolutePath), targetWithoutAnchor); + const relativeTarget = path.relative(workspaceRoot, absoluteTarget); + + if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) { + findings.push({ + check: 'markdown-link', + level: 'error', + message: 'Markdown 相对链接指向工作区外。', + path: relativePath, + target: rawTarget, + }); + continue; + } + + if (!existsSync(absoluteTarget)) { + findings.push({ + check: 'markdown-link', + level: 'error', + message: 'Markdown 相对链接目标不存在。', + path: relativePath, + target: rawTarget, + }); + } + } + } + + return linkCount; +} + +function hasNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function rectanglesOverlap(a: CanvasNode, b: CanvasNode): boolean { + if (!hasNumber(a.x) || !hasNumber(a.y) || !hasNumber(a.width) || !hasNumber(a.height)) return false; + if (!hasNumber(b.x) || !hasNumber(b.y) || !hasNumber(b.width) || !hasNumber(b.height)) return false; + + return ( + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y + ); +} + +function validateCanvas(findings: ObsidianFinding[], checkLayout: boolean): { edgeCount: number; nodeCount: number } { + const canvasPath = 'docs/obsidian/KT 关系图谱.canvas'; + const canvas = readJsonFile(canvasPath, findings); + if (!canvas) return { edgeCount: 0, nodeCount: 0 }; + + const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : []; + const edges = Array.isArray(canvas.edges) ? canvas.edges : []; + const nodeIds = new Set(); + const edgeIds = new Set(); + + for (const node of nodes) { + if (!node.id) { + findings.push({ + check: 'canvas-node', + level: 'error', + message: 'Canvas 节点缺少 id。', + path: canvasPath, + }); + continue; + } + + if (nodeIds.has(node.id)) { + findings.push({ + check: 'canvas-node', + level: 'error', + message: 'Canvas 节点 id 重复。', + path: canvasPath, + target: node.id, + }); + } + nodeIds.add(node.id); + + if (node.type === 'file' && node.file && !existsSync(resolveInsideRoot(node.file))) { + findings.push({ + check: 'canvas-file', + level: 'error', + message: 'Canvas 文件节点目标不存在。', + path: canvasPath, + target: node.file, + }); + } + } + + for (const edge of edges) { + if (!edge.id) { + findings.push({ + check: 'canvas-edge', + level: 'error', + message: 'Canvas 边缺少 id。', + path: canvasPath, + }); + continue; + } + + if (edgeIds.has(edge.id)) { + findings.push({ + check: 'canvas-edge', + level: 'error', + message: 'Canvas 边 id 重复。', + path: canvasPath, + target: edge.id, + }); + } + edgeIds.add(edge.id); + + if (!edge.fromNode || !nodeIds.has(edge.fromNode) || !edge.toNode || !nodeIds.has(edge.toNode)) { + findings.push({ + check: 'canvas-edge', + level: 'error', + message: 'Canvas 边引用了不存在的节点。', + path: canvasPath, + target: edge.id, + }); + } + } + + if (checkLayout) { + const visibleNodes = nodes.filter((node) => node.type !== 'group'); + for (let index = 0; index < visibleNodes.length; index += 1) { + for (let nextIndex = index + 1; nextIndex < visibleNodes.length; nextIndex += 1) { + const current = visibleNodes[index]; + const next = visibleNodes[nextIndex]; + if (current && next && rectanglesOverlap(current, next)) { + findings.push({ + check: 'canvas-layout', + level: 'warning', + message: 'Canvas 非分组节点存在重叠,可能影响图谱可读性。', + path: canvasPath, + target: `${current.id || 'unknown'} <-> ${next.id || 'unknown'}`, + }); + } + } + } + } + + return { edgeCount: edges.length, nodeCount: nodes.length }; +} + +function validateBaseFile(findings: ObsidianFinding[]): Record { + const basePath = 'docs/obsidian/KT 文档矩阵.base'; + if (!existsSync(resolveInsideRoot(basePath))) { + findings.push({ + check: 'base', + level: 'error', + message: 'Base 文件不存在。', + path: basePath, + }); + return { views: 0 }; + } + + const content = readTextFile(basePath); + const requiredTokens = ['filters:', 'properties:', 'views:', 'type: table', 'type: cards']; + for (const token of requiredTokens) { + if (!content.includes(token)) { + findings.push({ + check: 'base', + level: 'error', + message: `Base 文件缺少必要结构:${token}`, + path: basePath, + }); + } + } + + return { + hasCardsView: content.includes('type: cards'), + hasTableView: content.includes('type: table'), + views: [...content.matchAll(/^\s*-\s+type:\s+/gm)].length, + }; +} + +function validateStaleReferences(findings: ObsidianFinding[]): number { + const scanFiles = listFiles(workspaceRoot, new Set(['.md', '.ps1', '.sh', '.ts', '.yaml', '.yml'])); + let hitCount = 0; + + for (const absolutePath of scanFiles) { + const relativePath = relativeToWorkspace(absolutePath); + if (relativePath === 'mcp/ktWorkflow/src/tools/obsidian.ts') continue; + + const lines = readFileSync(absolutePath, 'utf8').split(/\r?\n/); + + lines.forEach((line, index) => { + const matchedPattern = staleReferencePatterns.find((pattern) => line.includes(pattern)); + if (!matchedPattern) return; + + hitCount += 1; + const allowed = allowedStaleReferenceHints.some((hint) => line.includes(hint)); + findings.push({ + check: 'stale-reference', + level: allowed ? 'info' : 'warning', + message: allowed ? '旧引用只作为禁用规则或清理记录保留。' : '发现旧入口或已删除文档引用,请确认是否还需要保留。', + path: `${relativePath}:${index + 1}`, + target: matchedPattern, + }); + }); + } + + return hitCount; +} + +function countByLevel(findings: ObsidianFinding[]): Record { + return findings.reduce>( + (result, finding) => { + result[finding.level] += 1; + return result; + }, + { error: 0, info: 0, warning: 0 }, + ); +} + +export function validateObsidianVault(input: ObsidianValidateInput = {}): Record { + const options = normalizeInput(input); + const findings: ObsidianFinding[] = []; + + for (const configFile of obsidianConfigFiles) { + readJsonFile>(configFile, findings); + } + + const base = validateBaseFile(findings); + const canvas = validateCanvas(findings, options.checkLayout); + const wikiLinkCount = validateWikiLinks(findings); + const markdownLinkCount = options.includeMarkdownLinks ? validateMarkdownLinks(findings) : 0; + const staleReferenceCount = options.includeStaleReferences ? validateStaleReferences(findings) : 0; + const levels = countByLevel(findings); + const ok = levels.error === 0 && (!options.failOnWarnings || levels.warning === 0); + + return { + checks: { + base, + canvas, + configFiles: obsidianConfigFiles.length, + markdownLinks: markdownLinkCount, + staleReferences: staleReferenceCount, + wikiLinks: wikiLinkCount, + }, + findings, + ok, + options, + summary: { + errors: levels.error, + infos: levels.info, + warnings: levels.warning, + }, + }; +} + +export function readObsidianContext(input: ObsidianContextInput = {}): Record { + const maxDocuments = Math.min(Math.max(input.maxDocuments || 8, 1), 30); + const terms = buildContextTerms(input); + const codeAnchors = buildCodeAnchors(terms, Math.min(maxDocuments, 8)); + const ruleAnchors = buildRuleAnchors(codeAnchors); + const includeContent = input.includeContent !== false; + const allPaths = Array.from(new Set([...defaultContextPaths, ...listObsidianMarkdownPaths()])); + const scoredPaths = allPaths + .map((relativePath) => ({ + relativePath, + score: scoreContextPath(relativePath, terms), + })) + .filter((item) => defaultContextPaths.includes(item.relativePath) || item.score > 0) + .sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + return left.relativePath.localeCompare(right.relativePath, 'zh-Hans-CN'); + }); + + const selectedPaths = scoredPaths.slice(0, maxDocuments).map((item) => item.relativePath); + const documents = selectedPaths.map((relativePath) => readContextDocument(relativePath, includeContent, terms)); + + return { + canvas: readCanvasSummary(input.includeCanvas), + codeAnchors, + commands: [ + 'pnpm --dir mcp/ktWorkflow run obsidian-context', + 'pnpm --dir mcp/ktWorkflow run obsidian-validate', + ], + documents, + ok: true, + route: { + defaultIndex: defaultContextPaths, + module: input.module?.trim() || null, + query: input.query?.trim() || null, + terms, + }, + ruleAnchors, + summary: { + allIndexedDocuments: allPaths.length, + codeAnchors: codeAnchors.length, + ruleAnchors: ruleAnchors.length, + returnedDocuments: documents.length, + }, + workflowAnchors: [ + { + input: { + includeFullDocs: false, + taskRecordCount: 3, + }, + name: 'kt_read_context', + purpose: '读取当前任务记录、项目清单和根目录规则,补齐 Obsidian 文档索引之外的运行上下文。', + }, + ...codeAnchors.map((anchor) => ({ + input: { + includeGit: true, + project: anchor.alias, + }, + name: 'kt_inspect_project', + purpose: `锚定 ${anchor.label} 的仓库状态、包管理器、Node 和 env。`, + })), + ], + }; +} + +function readOptionalJson(relativePath: string): T | null { + if (!existsSync(resolveInsideRoot(relativePath))) return null; + try { + return JSON.parse(readTextFile(relativePath)) as T; + } catch { + return null; + } +} + +function buildSyncFindings(): ObsidianFinding[] { + const findings: ObsidianFinding[] = []; + const missingFiles = expectedObsidianFiles.filter((file) => !existsSync(resolveInsideRoot(file))); + + missingFiles.forEach((file) => { + findings.push({ + check: 'expected-file', + level: 'error', + message: 'Obsidian 工作流入口文件缺失。', + path: file, + }); + }); + + const bookmarks = readOptionalJson('.obsidian/bookmarks.json'); + const bookmarkPaths = new Set((bookmarks?.items || []).map((item) => item.path).filter(Boolean)); + requiredBookmarkPaths + .filter((file) => !bookmarkPaths.has(file)) + .forEach((file) => { + findings.push({ + check: 'bookmarks', + level: 'warning', + message: '常用入口没有加入 Obsidian 书签。', + path: '.obsidian/bookmarks.json', + target: file, + }); + }); + + const corePlugins = readOptionalJson>('.obsidian/core-plugins.json') || {}; + requiredCorePlugins + .filter((pluginName) => corePlugins[pluginName] !== true) + .forEach((pluginName) => { + findings.push({ + check: 'core-plugins', + level: 'warning', + message: '推荐启用的 Obsidian 核心插件没有开启。', + path: '.obsidian/core-plugins.json', + target: pluginName, + }); + }); + + const appSettings = readOptionalJson('.obsidian/app.json'); + const ignoredRoots = new Set(appSettings?.userIgnoreFilters || []); + expectedIgnoredRoots + .filter((root) => !ignoredRoots.has(root)) + .forEach((root) => { + findings.push({ + check: 'app-ignore', + level: 'warning', + message: '源码或运行态目录没有加入 Obsidian 忽略列表,可能污染关系图。', + path: '.obsidian/app.json', + target: root, + }); + }); + + if (appSettings?.attachmentFolderPath !== 'docs/assets/obsidian') { + findings.push({ + check: 'attachments', + level: 'warning', + message: '附件目录建议固定到 docs/assets/obsidian。', + path: '.obsidian/app.json', + target: appSettings?.attachmentFolderPath || '', + }); + } + + return findings; +} + +export function syncObsidianWorkflow(input: ObsidianSyncInput = {}): Record { + const includeValidation = input.includeValidation !== false; + const syncFindings = buildSyncFindings(); + const validation = includeValidation ? validateObsidianVault(input) : null; + const validationFindings = (validation?.findings || []) as ObsidianFinding[]; + const findings = [...syncFindings, ...validationFindings]; + const levels = countByLevel(findings); + const ok = levels.error === 0 && (input.failOnWarnings !== true || levels.warning === 0); + + return { + commands: [ + 'pnpm --dir mcp/ktWorkflow run obsidian-validate', + 'pnpm --dir mcp/ktWorkflow run obsidian-sync', + 'pnpm --dir mcp/ktWorkflow run self-test', + ], + findings, + nextActions: + findings.length === 0 + ? ['Obsidian 图谱、配置和工作流入口已连通;后续文档改动后复跑 obsidian-validate。'] + : ['先修复 error;warning 确认后可进入 TASKS.md 或 docs/obsidian 记录。'], + ok, + summary: { + errors: levels.error, + expectedFiles: expectedObsidianFiles.length, + infos: levels.info, + warnings: levels.warning, + }, + validation, + }; +} diff --git a/src/tools/task.ts b/src/tools/task.ts index 70b25d3..06e54fe 100644 --- a/src/tools/task.ts +++ b/src/tools/task.ts @@ -155,6 +155,26 @@ export function readWorkflowContext(input: WorkflowContextInput): Record', + }, + name: 'kt_inspect_project', + purpose: '读取被 Obsidian 索引锚定的真实仓库状态和代码入口。', + }, + ], + }, projects, workspaceRoot, }; diff --git a/src/tools/testing.ts b/src/tools/testing.ts index 261294e..0534f21 100644 --- a/src/tools/testing.ts +++ b/src/tools/testing.ts @@ -19,7 +19,7 @@ export function createPageTestCase(input: PageTestCaseInput): PageTestCaseResult '关闭浏览器实例', '清理本次启动的 Node/Vite 进程', '保存截图和 result.json', - '调用 kt_cleanup_history,仅保留最近 3 轮历史产物', + '先调用 kt_cleanup_history dryRun=true 预览,再执行 dryRun=false 或 pnpm run cleanup-history -- --execute,仅保留最近 3 轮历史产物并保留模板目录', ], entryUrl: input.entryUrl || '待填写', maxRounds: 3, @@ -179,6 +179,7 @@ export function buildBusinessTestPlan(input: BusinessTestPlanInput = {}): Record '业务测试先写用例,再按环境 -> 账号 -> 数据 -> 接口 -> 页面预检。', '失败必须记录复现步骤、证据和初步归因。', '同一用例最多三轮,第三轮仍失败则停止等待建议。', + '每轮结束后执行 kt_cleanup_history 预览;如存在待删历史产物,确认范围后执行清理,只保留最近 3 轮并保留模板目录。', ], selectedFlows: selected.map(([name, plan]) => ({ name, diff --git a/src/tools/workflow.ts b/src/tools/workflow.ts index 798bf37..fdb340b 100644 --- a/src/tools/workflow.ts +++ b/src/tools/workflow.ts @@ -107,6 +107,8 @@ export async function buildFinishTask(input: FinishTaskInput = {}): Promise0 说明测试/验证历史还未满足最近 3 轮规则。', '提交和推送仍需用户明确要求后执行。', karpathyFinishNote, ], @@ -132,12 +134,14 @@ export async function buildFinishTask(input: FinishTaskInput = {}): Promise snapshot.project.relativePath), + cleanupPendingCount, validationRunCount: validationRuns.length, }, validationRuns, diff --git a/src/types.ts b/src/types.ts index 71d5e17..572b849 100644 --- a/src/types.ts +++ b/src/types.ts @@ -262,6 +262,49 @@ export interface WorkstreamCloseoutInput { verificationEvidence?: string[]; } +export interface ObsidianValidateInput { + checkLayout?: boolean; + failOnWarnings?: boolean; + includeMarkdownLinks?: boolean; + includeStaleReferences?: boolean; +} + +export interface ObsidianSyncInput extends ObsidianValidateInput { + includeValidation?: boolean; +} + +export interface ObsidianContextInput { + includeCanvas?: boolean; + includeContent?: boolean; + maxDocuments?: number; + module?: string; + query?: string; +} + +export interface WorkflowLoopAuditInput { + changedProjects?: string[]; + cleanupExecuted?: boolean; + cleanupPreviewDeleted?: number; + docSyncEvidence?: string[]; + failureRecords?: string[]; + ktWorkflowUpdated?: boolean; + problemRecords?: string[]; + requireCompletion?: boolean; + reusablePattern?: boolean; + reviewEvidence?: string[]; + stableSolutions?: string[]; + stage?: 'failure' | 'finish' | 'start' | 'test'; + taskTitle?: string; + testCases?: string[]; + verificationEvidence?: string[]; +} + +export interface ChangeDocSyncInput { + changedFiles?: string[]; + project?: string; + taskType?: TaskType; +} + export interface VerificationPlanResult { changeType: TaskType; commands: string[];