add-coder 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +22 -17
  2. package/dist/index.js +10 -0
  3. package/package.json +4 -11
  4. package/templates/adapters/claude/hooks/doc-format-guard.sh +17 -0
  5. package/templates/adapters/claude/hooks/lib/notify.sh +34 -0
  6. package/templates/adapters/claude/hooks/pre-tool-use.sh +26 -7
  7. package/templates/adapters/claude/hooks/prompt-submit.sh +16 -0
  8. package/templates/adapters/codex/hooks/doc-format-guard.sh +17 -0
  9. package/templates/adapters/codex/hooks/lib/notify.sh +34 -0
  10. package/templates/adapters/codex/hooks/pre-tool-use.sh +48 -18
  11. package/templates/adapters/codex/hooks/prompt-submit.sh +16 -0
  12. package/templates/adapters/qoder/hooks/doc-format-guard.sh +17 -0
  13. package/templates/adapters/qoder/hooks/lib/notify.sh +34 -0
  14. package/templates/adapters/qoder/hooks/pre-tool-use.sh +28 -9
  15. package/templates/adapters/qoder/hooks/prompt-submit.sh +18 -1
  16. package/templates/adapters/trae/hooks/doc-format-guard.sh +17 -0
  17. package/templates/adapters/trae/hooks/lib/notify.sh +34 -0
  18. package/templates/adapters/trae/hooks/pre-tool-use.sh +48 -18
  19. package/templates/adapters/trae/hooks/prompt-submit.sh +16 -0
  20. package/templates/adapters/vscode/hooks/doc-format-guard.sh +16 -0
  21. package/templates/adapters/vscode/hooks/lib/notify.sh +34 -0
  22. package/templates/adapters/vscode/hooks/pre-tool-use.sh +26 -7
  23. package/templates/adapters/vscode/hooks/prompt-submit.sh +16 -0
  24. package/templates/core/hooks/doc-format-guard.sh +17 -0
  25. package/templates/core/hooks/lib/notify.sh +34 -0
  26. package/templates/core/hooks/pre-tool-use.sh +48 -18
  27. package/templates/core/hooks/prompt-submit.sh +16 -0
  28. package/templates/core/reviews/.gitkeep +0 -0
  29. package/templates/core/scripts/mcp-server/elicitation/confirm.ts +30 -0
  30. package/templates/core/scripts/mcp-server/elicitation/index.ts +1 -0
  31. package/templates/core/scripts/mcp-server/index.ts +15 -0
  32. package/templates/core/scripts/mcp-server/notifications/hitl.ts +49 -0
  33. package/templates/core/scripts/mcp-server/notifications/hook.ts +268 -0
  34. package/templates/core/scripts/mcp-server/notifications/index.ts +8 -0
  35. package/templates/core/scripts/mcp-server/resources/add-coder-version.ts +25 -0
  36. package/templates/core/scripts/mcp-server/resources/add-state.ts +44 -0
  37. package/templates/core/scripts/mcp-server/resources/hook-events-report.ts +104 -0
  38. package/templates/core/scripts/mcp-server/resources/index.ts +12 -0
  39. package/templates/core/scripts/mcp-server/resources/round-task.ts +22 -0
  40. package/templates/core/scripts/mcp-server/sampling/index.ts +1 -0
  41. package/templates/core/scripts/mcp-server/sampling/review.ts +47 -0
  42. package/templates/core/scripts/mcp-server/shared/env.ts +26 -0
  43. package/templates/core/scripts/mcp-server/shared/fs.ts +40 -0
  44. package/templates/core/scripts/mcp-server/shared/prisma.ts +35 -0
  45. package/templates/core/scripts/mcp-server/shared/response.ts +9 -0
  46. package/templates/core/scripts/mcp-server/tasks/index.ts +2 -0
  47. package/templates/core/scripts/mcp-server/tasks/runner.ts +20 -0
  48. package/templates/core/scripts/mcp-server/tasks/store.ts +20 -0
  49. package/templates/core/scripts/mcp-server/tools/audit.ts +73 -0
  50. package/templates/core/scripts/mcp-server/tools/context.ts +337 -0
  51. package/templates/core/scripts/mcp-server/tools/docs.ts +42 -0
  52. package/templates/core/scripts/mcp-server/tools/gateway.ts +135 -0
  53. package/templates/core/scripts/mcp-server/tools/hook-event-report.ts +92 -0
  54. package/templates/core/scripts/mcp-server/tools/index.ts +17 -0
  55. package/templates/core/scripts/mcp-server/tools/quality.ts +93 -0
  56. package/templates/core/scripts/mcp-server/types.ts +7 -0
  57. package/templates/core/scripts/mcp-server.ts +8 -3455
@@ -0,0 +1,8 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server"
2
+ import { registerHitlNotifications } from "./hitl.js"
3
+ import { registerHookNotifications } from "./hook.js"
4
+
5
+ export function registerAllNotifications(server: McpServer) {
6
+ registerHitlNotifications(server)
7
+ registerHookNotifications(server)
8
+ }
@@ -0,0 +1,25 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server"
2
+ import { join } from "path"
3
+ import { readFileSafe, PROJECT_ROOT } from "../shared/fs.js"
4
+ import { spawnSync } from "child_process"
5
+
6
+ interface PkgJson { version?: string }
7
+
8
+ export function registerVersionResource(server: McpServer) {
9
+ server.registerResource("add-coder-version", "add-coder://version",
10
+ { description: "add-coder npm 包版本信息(当前安装 vs 最新发布)", mimeType: "application/json" },
11
+ async () => {
12
+ const pkgPath = join(PROJECT_ROOT, "package.json")
13
+ const pkg = await readFileSafe(pkgPath)
14
+ let current = "unknown", latest = "unknown", outdated = false
15
+ if (pkg) {
16
+ try { current = (JSON.parse(pkg) as PkgJson).version ?? "unknown" } catch { /* intentionally empty */ }
17
+ }
18
+ try {
19
+ const result = spawnSync("npm", ["view", "add-coder", "version"], { encoding: "utf-8", timeout: 10000 })
20
+ latest = (result.stdout || "").trim() || "unknown"
21
+ outdated = current !== "unknown" && latest !== "unknown" && current !== latest
22
+ } catch { /* intentionally empty */ }
23
+ return { contents: [{ text: JSON.stringify({ current, latest, outdated }), uri: "add-coder://version", mimeType: "application/json" }] }
24
+ })
25
+ }
@@ -0,0 +1,44 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server"
2
+ import { join } from "path"
3
+ import { existsSync } from "fs"
4
+ import { readdirRecursive, PROJECT_ROOT, MAGIC_DIR } from "../shared/fs.js"
5
+
6
+ export function registerAddStateResources(server: McpServer) {
7
+
8
+ server.registerResource("plan-status", "add-coder://plan/status",
9
+ { description: "当前活跃 ADD Plan 的状态信息", mimeType: "application/json" },
10
+ async () => {
11
+ const plansDir = join(PROJECT_ROOT, MAGIC_DIR, "plans")
12
+ if (!existsSync(plansDir)) return { contents: [{ text: JSON.stringify({ active: false, message: "无 plans 目录" }), uri: "add-coder://plan/status", mimeType: "application/json" }] }
13
+ const files = (await readdirRecursive(plansDir)).filter(f => f.endsWith(".md") && !f.includes("add-route") && !f.includes("handoff"))
14
+ const active = files.length > 0 ? files[files.length - 1].replace(".md", "") : null
15
+ return { contents: [{ text: JSON.stringify({ active, total: files.length }), uri: "add-coder://plan/status", mimeType: "application/json" }] }
16
+ })
17
+
18
+ server.registerResource("review-status", "add-coder://review/status",
19
+ { description: "当前活跃 ADD Review 的状态信息", mimeType: "application/json" },
20
+ async () => {
21
+ const reviewsDir = join(PROJECT_ROOT, MAGIC_DIR, "reviews")
22
+ if (!existsSync(reviewsDir)) return { contents: [{ text: JSON.stringify({ active: false }), uri: "add-coder://review/status", mimeType: "application/json" }] }
23
+ const files = (await readdirRecursive(reviewsDir)).filter(f => f.endsWith(".md"))
24
+ return { contents: [{ text: JSON.stringify({ active: files.length > 0, total: files.length }), uri: "add-coder://review/status", mimeType: "application/json" }] }
25
+ })
26
+
27
+ server.registerResource("route-status", "add-coder://route/status",
28
+ { description: "ADD Route 执行状态", mimeType: "application/json" },
29
+ async () => {
30
+ const plansDir = join(PROJECT_ROOT, MAGIC_DIR, "plans")
31
+ if (!existsSync(plansDir)) return { contents: [{ text: JSON.stringify({ found: false }), uri: "add-coder://route/status", mimeType: "application/json" }] }
32
+ const files = (await readdirRecursive(plansDir)).filter(f => f.includes("add-route"))
33
+ return { contents: [{ text: JSON.stringify({ found: files.length > 0, files }), uri: "add-coder://route/status", mimeType: "application/json" }] }
34
+ })
35
+
36
+ server.registerResource("specs-status", "add-coder://specs/status",
37
+ { description: "ADD Specs 状态", mimeType: "application/json" },
38
+ async () => {
39
+ const specsDir = join(PROJECT_ROOT, MAGIC_DIR, "specs")
40
+ if (!existsSync(specsDir)) return { contents: [{ text: JSON.stringify({ found: false }), uri: "add-coder://specs/status", mimeType: "application/json" }] }
41
+ const dirs = (await readdirRecursive(specsDir)).filter(f => !f.includes("/"))
42
+ return { contents: [{ text: JSON.stringify({ found: dirs.length > 0, specs: dirs }), uri: "add-coder://specs/status", mimeType: "application/json" }] }
43
+ })
44
+ }
@@ -0,0 +1,104 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server"
2
+ import { prisma } from "../shared/prisma.js"
3
+
4
+ export function registerHookEventResources(server: McpServer) {
5
+
6
+ // ── 日报 Resource ──
7
+ server.registerResource("hook-events-daily", "add-coder://report/hook-events/daily",
8
+ { description: "过去 24 小时 Hook 拦截事件日报(按小时分组聚合)", mimeType: "application/json" },
9
+ async () => {
10
+ try {
11
+ const ops = prisma.devOperation as Record<string, (...a: unknown[]) => unknown>
12
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1000)
13
+ const logs = await ops.findMany({
14
+ where: { action: "HOOK_INTERCEPT", createdAt: { gte: since } },
15
+ orderBy: { createdAt: "desc" },
16
+ take: 500,
17
+ }) as Array<Record<string, unknown>>
18
+
19
+ // 按小时分组
20
+ const hourly = new Map<string, { total: number; plans: Map<string, number> }>()
21
+ for (const l of logs) {
22
+ const hour = (l.createdAt as Date).toISOString().slice(0, 13) + ":00"
23
+ const kw = (l.planKeyword as string) || "unknown"
24
+ if (!hourly.has(hour)) hourly.set(hour, { total: 0, plans: new Map() })
25
+ const entry = hourly.get(hour)!
26
+ entry.total++
27
+ entry.plans.set(kw, (entry.plans.get(kw) || 0) + 1)
28
+ }
29
+
30
+ const breakdown: Array<{ hour: string; total: number; plans: Record<string, number> }> = []
31
+ for (const [hour, entry] of [...hourly.entries()].sort()) {
32
+ const plans: Record<string, number> = {}
33
+ for (const [k, v] of entry.plans) plans[k] = v
34
+ breakdown.push({ hour, total: entry.total, plans })
35
+ }
36
+
37
+ return {
38
+ contents: [{
39
+ text: JSON.stringify({ type: "daily", total: logs.length, breakdown }),
40
+ uri: "add-coder://report/hook-events/daily",
41
+ mimeType: "application/json",
42
+ }],
43
+ }
44
+ } catch {
45
+ return {
46
+ contents: [{
47
+ text: JSON.stringify({ type: "daily", total: 0, breakdown: [], error: "数据库不可用" }),
48
+ uri: "add-coder://report/hook-events/daily",
49
+ mimeType: "application/json",
50
+ }],
51
+ }
52
+ }
53
+ })
54
+
55
+ // ── 周报 Resource ──
56
+ server.registerResource("hook-events-weekly", "add-coder://report/hook-events/weekly",
57
+ { description: "过去 7 天 Hook 拦截事件周报(按日分组聚合)", mimeType: "application/json" },
58
+ async () => {
59
+ try {
60
+ const ops = prisma.devOperation as Record<string, (...a: unknown[]) => unknown>
61
+ const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
62
+ const logs = await ops.findMany({
63
+ where: { action: "HOOK_INTERCEPT", createdAt: { gte: since } },
64
+ orderBy: { createdAt: "desc" },
65
+ take: 2000,
66
+ }) as Array<Record<string, unknown>>
67
+
68
+ // 按日分组
69
+ const daily = new Map<string, { total: number; plans: Map<string, number> }>()
70
+ for (const l of logs) {
71
+ const day = (l.createdAt as Date).toISOString().slice(0, 10)
72
+ const kw = (l.planKeyword as string) || "unknown"
73
+ if (!daily.has(day)) daily.set(day, { total: 0, plans: new Map() })
74
+ const entry = daily.get(day)!
75
+ entry.total++
76
+ entry.plans.set(kw, (entry.plans.get(kw) || 0) + 1)
77
+ }
78
+
79
+ const breakdown: Array<{ day: string; total: number; plans: Record<string, number> }> = []
80
+ for (const [day, entry] of [...daily.entries()].sort()) {
81
+ const plans: Record<string, number> = {}
82
+ for (const [k, v] of entry.plans) plans[k] = v
83
+ breakdown.push({ day, total: entry.total, plans })
84
+ }
85
+
86
+ return {
87
+ contents: [{
88
+ text: JSON.stringify({ type: "weekly", total: logs.length, breakdown }),
89
+ uri: "add-coder://report/hook-events/weekly",
90
+ mimeType: "application/json",
91
+ }],
92
+ }
93
+ } catch {
94
+ return {
95
+ contents: [{
96
+ text: JSON.stringify({ type: "weekly", total: 0, breakdown: [], error: "数据库不可用" }),
97
+ uri: "add-coder://report/hook-events/weekly",
98
+ mimeType: "application/json",
99
+ }],
100
+ }
101
+ }
102
+ })
103
+
104
+ }
@@ -0,0 +1,12 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server"
2
+ import { registerAddStateResources } from "./add-state.js"
3
+ import { registerRoundTaskResources } from "./round-task.js"
4
+ import { registerVersionResource } from "./add-coder-version.js"
5
+ import { registerHookEventResources } from "./hook-events-report.js"
6
+
7
+ export function registerAllResources(server: McpServer) {
8
+ registerAddStateResources(server)
9
+ registerRoundTaskResources(server)
10
+ registerVersionResource(server)
11
+ registerHookEventResources(server) // 2 resources: hook-events/{daily,weekly}
12
+ }
@@ -0,0 +1,22 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server"
2
+ import { join } from "path"
3
+ import { existsSync } from "fs"
4
+ import { readFileSafe, readdirRecursive, PROJECT_ROOT, MAGIC_DIR } from "../shared/fs.js"
5
+
6
+ export function registerRoundTaskResources(server: McpServer) {
7
+ server.registerResource("round-task", "add-coder://round/{round}/task/{task}",
8
+ { description: "指定轮次的任务完成状态(从 Handoff 文件解析)", mimeType: "application/json" },
9
+ async (uri) => {
10
+ const round = uri.pathname.split("/")[2]
11
+ const task = uri.pathname.split("/")[4]
12
+ const plansDir = join(PROJECT_ROOT, MAGIC_DIR, "plans")
13
+ if (!existsSync(plansDir)) return { contents: [{ text: JSON.stringify({ found: false }), uri: uri.href, mimeType: "application/json" }] }
14
+ const files = await readdirRecursive(plansDir)
15
+ const handoff = files.find(f => f.includes("handoff"))
16
+ if (!handoff) return { contents: [{ text: JSON.stringify({ found: false, reason: "无 Handoff 文件" }), uri: uri.href, mimeType: "application/json" }] }
17
+ const content = await readFileSafe(join(plansDir, handoff)) || ""
18
+ const tasks = content.match(/- \[[ x]\]/g) || []
19
+ const done = tasks.filter(t => t.includes("x")).length
20
+ return { contents: [{ text: JSON.stringify({ found: true, round, task, done, totalTasks: tasks.length }), uri: uri.href, mimeType: "application/json" }] }
21
+ })
22
+ }
@@ -0,0 +1 @@
1
+ export { createReviewRequest } from "./review.js"
@@ -0,0 +1,47 @@
1
+ import { inputRequired, type InputRequiredResult } from "@modelcontextprotocol/server"
2
+ import { join } from "path"
3
+ import { readFileSafe, PROJECT_ROOT, MAGIC_DIR } from "../shared/fs.js"
4
+
5
+ /**
6
+ * HITL Review 触发
7
+ * ADD-9 方向验证 / ADD-10 语义对齐 / ADD-11 证据持久化
8
+ *
9
+ * 流程: 读取 Review 模板 → 生成 HITL 发现总览 temporary.md → 人类拍板 → 写入正式 Review
10
+ * 参考: add-paradigm SKILL Step 0.6.5(Review 结论回流至 Plan 与 Specs)
11
+ */
12
+ export async function createReviewRequest(planKeyword: string, reviewType: "plan" | "implementation" | "runtime" = "plan"): Promise<InputRequiredResult> {
13
+ const templateFile = reviewType === "plan"
14
+ ? "review-template.md"
15
+ : reviewType === "implementation"
16
+ ? "review-implementation-template.md"
17
+ : "review-runtime-template.md"
18
+
19
+ const templatePath = join(PROJECT_ROOT, MAGIC_DIR, "templates", templateFile)
20
+ const template = await readFileSafe(templatePath)
21
+
22
+ const hitlGuide = `
23
+ ## HITL 审核流程(两步法)
24
+
25
+ 1. 先写 {review-name}.temporary.md(只含 HITL 发现总览表 + 问题清单)
26
+ 2. 人类拍板后 → 生成完整 Review 写入 ${MAGIC_DIR}/reviews/
27
+ 3. 将 Review 结论回流至 Plan(Step 0.6.5)
28
+
29
+ 模板:
30
+ ${template?.slice(0, 3000) ?? `标准 ${reviewType} Review 模板`}
31
+ `
32
+
33
+ const prompt = `请为 Plan "${planKeyword}" 发起 HITL Review(类型: ${reviewType})。
34
+ 先读取 ${MAGIC_DIR}/reviews/ 下已有的 Review 模板,
35
+ 然后按 HITL 两步法:先写 temporary.md → 人类拍板 → 生成完整 Review。
36
+
37
+ ${hitlGuide}`
38
+
39
+ const sampleRequest = inputRequired.createMessage({
40
+ messages: [{ role: "user" as const, content: { type: "text" as const, text: prompt } }],
41
+ maxTokens: 4000
42
+ })
43
+
44
+ return inputRequired({
45
+ inputRequests: { sample: sampleRequest }
46
+ })
47
+ }
@@ -0,0 +1,26 @@
1
+ import dotenv from "dotenv"
2
+ import { dirname, resolve, basename } from "path"
3
+ import { fileURLToPath } from "url"
4
+ import { existsSync } from "fs"
5
+
6
+ const __filename = fileURLToPath(import.meta.url)
7
+ const __dirname = dirname(__filename)
8
+
9
+ export const PROJECT_ROOT = resolve(__dirname, "..", "..", "..", "..")
10
+ export const MAGIC_DIR = basename(resolve(__dirname, "..", "..", ".."))
11
+ export const PROJECT_ID = basename(PROJECT_ROOT)
12
+
13
+ const ENV_CANDIDATES = [".env.development.local", ".env.development", ".env.local", ".env"]
14
+ let loaded = false
15
+ for (const base of [PROJECT_ROOT, process.cwd()]) {
16
+ if (loaded) break
17
+ for (const f of ENV_CANDIDATES) {
18
+ const p = resolve(base, f)
19
+ if (existsSync(p)) { dotenv.config({ path: p, override: true }); loaded = true; break }
20
+ }
21
+ }
22
+
23
+ export const DATABASE_URL = process.env.DATABASE_URL
24
+ if (!DATABASE_URL) {
25
+ throw new Error("DATABASE_URL 未设置,请在 .env 中配置数据库连接串")
26
+ }
@@ -0,0 +1,40 @@
1
+ import { readFile, readdir } from "fs/promises"
2
+ import { join, relative } from "path"
3
+ import { existsSync } from "fs"
4
+ import { spawnSync } from "child_process"
5
+ import { PROJECT_ROOT, MAGIC_DIR } from "./env.js"
6
+ import type { GuardResult } from "../types.js"
7
+
8
+ export async function readFileSafe(filePath: string): Promise<string | null> {
9
+ try { return await readFile(filePath, "utf-8") } catch { return null }
10
+ }
11
+
12
+ export async function validateDocWithGuard(filePath: string): Promise<GuardResult> {
13
+ const guardScript = join(PROJECT_ROOT, MAGIC_DIR, "hooks", "doc-format-guard.sh")
14
+ if (!existsSync(guardScript)) return { ok: true, issues: "" }
15
+ const content = await readFileSafe(filePath)
16
+ if (!content) return { ok: false, issues: "文件无法读取" }
17
+ const guardInput = JSON.stringify({ tool_input: { file_path: filePath, file_content: content } })
18
+ const result = spawnSync("bash", [guardScript], { input: guardInput, encoding: "utf-8", timeout: 5000 })
19
+ if (result.status !== 0) return { ok: false, issues: result.stderr || "guard 执行失败" }
20
+ return { ok: true, issues: "" }
21
+ }
22
+
23
+ export async function readdirRecursive(baseDir: string): Promise<string[]> {
24
+ const results: string[] = []
25
+ async function walk(dir: string) {
26
+ const entries = await readdir(dir, { withFileTypes: true })
27
+ for (const entry of entries) {
28
+ const fullPath = join(dir, entry.name)
29
+ if (entry.isDirectory()) {
30
+ await walk(fullPath)
31
+ } else {
32
+ results.push(relative(baseDir, fullPath))
33
+ }
34
+ }
35
+ }
36
+ await walk(baseDir)
37
+ return results
38
+ }
39
+
40
+ export { PROJECT_ROOT, MAGIC_DIR }
@@ -0,0 +1,35 @@
1
+ import { DATABASE_URL, PROJECT_ROOT } from "./env.js"
2
+ import { join, dirname, resolve as pathResolve } from "path"
3
+ import { existsSync } from "fs"
4
+ import { fileURLToPath } from "url"
5
+
6
+ // 多路径回退:PROJECT_ROOT 可能因 cwd/沙箱环境算错
7
+ const candidates = [
8
+ join(PROJECT_ROOT, "src/generated/prisma/client.ts"),
9
+ join(PROJECT_ROOT, "src/generated/prisma/client.js"),
10
+ join(process.cwd(), "src/generated/prisma/client.ts"),
11
+ join(process.cwd(), "src/generated/prisma/client.js"),
12
+ // 从当前文件位置反推:shared/prisma.ts → 上 4 层到项目根
13
+ (() => { const d = dirname(fileURLToPath(import.meta.url)); const root = pathResolve(d, "..", "..", "..", ".."); return join(root, "src/generated/prisma/client.ts") })(),
14
+ (() => { const d = dirname(fileURLToPath(import.meta.url)); const root = pathResolve(d, "..", "..", "..", ".."); return join(root, "src/generated/prisma/client.js") })(),
15
+ ]
16
+ let prismaClientPath = candidates[0]
17
+ for (const p of candidates) { if (existsSync(p)) { prismaClientPath = p; break } }
18
+ const prismaModule: Record<string, unknown> = await import(prismaClientPath) as Record<string, unknown>
19
+ const PrismaClient = (prismaModule.PrismaClient || prismaModule.default) as new (opts?: Record<string, unknown>) => Record<string, unknown>
20
+
21
+ let adapter: Record<string, unknown> | undefined
22
+ if (!DATABASE_URL) throw new Error("DATABASE_URL required")
23
+ const url: string = DATABASE_URL
24
+ if (url.startsWith("postgresql://") || url.startsWith("postgres://")) {
25
+ try {
26
+ const pg = await import("@prisma/adapter-pg") as Record<string, unknown>
27
+ const Pg = pg.PrismaPg as new (opts: Record<string, unknown>) => Record<string, unknown>
28
+ adapter = new Pg({ connectionString: url })
29
+ } catch { /* optional dep */ }
30
+ }
31
+
32
+ export const prisma: Record<string, Record<string, (...a: unknown[]) => unknown>> = new PrismaClient({
33
+ ...(adapter ? { adapter } : {}),
34
+ log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
35
+ }) as Record<string, Record<string, (...a: unknown[]) => unknown>>
@@ -0,0 +1,9 @@
1
+ import type { ToolResponse } from "../types.js"
2
+
3
+ export function textResponse(text: string): { content: ToolResponse } {
4
+ return { content: [{ type: "text", text }] }
5
+ }
6
+
7
+ export function errorResponse(message: string): { content: ToolResponse; isError: boolean } {
8
+ return { content: [{ type: "text", text: message }], isError: true }
9
+ }
@@ -0,0 +1,2 @@
1
+ export { enqueueTask, getTaskStatus, getAllTasks, runTask, type Task } from "./runner.js"
2
+ export { persistTask } from "./store.js"
@@ -0,0 +1,20 @@
1
+ type TaskStatus = "pending" | "running" | "done" | "failed"
2
+ export interface Task { id: string; type: string; status: TaskStatus; progress: number; result?: string; createdAt: Date }
3
+
4
+ const taskQueue: Task[] = []
5
+
6
+ export function enqueueTask(type: string): string {
7
+ const id = `task-${Date.now()}-${Math.random().toString(36).slice(2,6)}`
8
+ taskQueue.push({ id, type, status: "pending", progress: 0, createdAt: new Date() })
9
+ return id
10
+ }
11
+
12
+ export function getTaskStatus(id: string): Task | undefined { return taskQueue.find(t => t.id === id) }
13
+
14
+ export function getAllTasks(): Task[] { return [...taskQueue] }
15
+
16
+ export async function runTask(id: string, handler: () => Promise<string>) {
17
+ const task = taskQueue.find(t => t.id === id); if (!task) return
18
+ task.status = "running"; task.progress = 10
19
+ try { task.result = await handler(); task.status = "done"; task.progress = 100 } catch (e) { task.status = "failed"; task.result = e instanceof Error ? e.message : String(e) }
20
+ }
@@ -0,0 +1,20 @@
1
+ import { prisma } from "../shared/prisma.js"
2
+ import type { Task } from "./runner.js"
3
+
4
+ export async function persistTask(task: Task): Promise<void> {
5
+ try {
6
+ const typedPrisma = prisma as Record<string, Record<string, (...a: unknown[]) => unknown>>
7
+ await typedPrisma.auditLog.create({
8
+ data: {
9
+ userId: "system",
10
+ action: `TASK_${task.status.toUpperCase()}`,
11
+ targetType: "TASK",
12
+ targetId: task.id,
13
+ reason: task.type,
14
+ afterState: { progress: task.progress, result: task.result ?? null }
15
+ }
16
+ })
17
+ } catch { /* intentionally empty */ }
18
+ }
19
+
20
+ export type { Task }
@@ -0,0 +1,73 @@
1
+ import * as z from "zod/v4"
2
+ import type { McpServer } from "@modelcontextprotocol/server"
3
+ import { textResponse, errorResponse } from "../shared/response.js"
4
+ import { prisma } from "../shared/prisma.js"
5
+
6
+ export function registerAuditTools(server: McpServer) {
7
+
8
+ // ===== query_audit_logs (L992-1109) =====
9
+ server.registerTool("query_audit_logs", {
10
+ description: "稀疏查询开发操作审计日志(DevOperation 表,MCP-5 稀疏推理恢复)。支持多维度检索,AI 可在不同对话会话中通过任意维度组合查询之前的开发操作记录,实现跨会话的上下文恢复。\n\n典型用法:\n- query_audit_logs({ targetType: \"API_ROUTE\" }) — 查所有 API 路由改动\n- query_audit_logs({ targetId: \"src/app/api/knowledge/route.ts\" }) — 查特定文件改动\n- query_audit_logs({ keyword: \"pagination\" }) — 按关键词搜索\n- query_audit_logs({ planKeyword: \"add-coder\" }) — 按 Plan 关键词查该 Plan 下所有 devlog\n- query_audit_logs({}) — 查最近的记录(session-init 会话恢复)",
11
+ inputSchema: z.object({
12
+ targetType: z.string().optional().describe("按目标类型精确过滤"),
13
+ action: z.string().optional().describe("按操作类型精确过滤"),
14
+ targetId: z.string().optional().describe("按目标标识精确过滤"),
15
+ planKeyword: z.string().optional().describe("按 Plan 关键词过滤"),
16
+ keyword: z.string().optional().describe("关键词搜索"),
17
+ sinceMinutes: z.number().optional().describe("时间窗口起始(分钟前)"),
18
+ limit: z.number().optional().default(20).describe("返回最大条数,默认 20,最大 100"),
19
+ }),
20
+ }, async (args: Record<string, string | number | undefined>, _ctx: unknown) => {
21
+ try {
22
+ const { targetType, action, targetId, planKeyword, keyword, sinceMinutes, limit = 20 } = args
23
+ const where: Record<string, unknown> = {}
24
+ if (sinceMinutes !== undefined) where.createdAt = { gte: new Date(Date.now() - sinceMinutes * 60 * 1000) }
25
+ if (targetType) where.targetType = targetType; if (action) where.action = action; if (targetId) where.targetId = targetId; if (planKeyword) where.planKeyword = planKeyword
26
+ if (keyword) where.OR = [{ action: { contains: keyword, mode: "insensitive" } },{ targetType: { contains: keyword, mode: "insensitive" } },{ targetId: { contains: keyword, mode: "insensitive" } },{ reason: { contains: keyword, mode: "insensitive" } },{ planKeyword: { contains: keyword, mode: "insensitive" } }]
27
+ const logs = await prisma.devOperation.findMany({ where, orderBy: { createdAt: "desc" }, take: Math.min(limit, 100), include: { user: { select: { username: true } } } })
28
+ if (logs.length === 0) {
29
+ const f: string[] = []; if (targetType) f.push(`targetType=${targetType}`); if (action) f.push(`action=${action}`); if (targetId) f.push(`targetId=${targetId}`); if (planKeyword) f.push(`planKeyword=${planKeyword}`); if (keyword) f.push(`keyword="${keyword}"`); if (sinceMinutes !== undefined) f.push(`sinceMinutes=${sinceMinutes}`)
30
+ return textResponse(`=== 开发操作审计日志 ===\n\n未找到匹配的审计记录(条件: ${f.length > 0 ? f.join(", ") : "无条件"})。\n\n可能原因: 数据库未运行、尚无相关开发操作记录、或 filter 过于严格。`)
31
+ }
32
+ const fl: string[] = []; if (targetType) fl.push(`targetType=${targetType}`); if (action) fl.push(`action=${action}`); if (targetId) fl.push(`targetId=${targetId}`); if (planKeyword) fl.push(`planKeyword=${planKeyword}`); if (keyword) fl.push(`keyword="${keyword}"`); if (sinceMinutes !== undefined) fl.push(`最近${sinceMinutes}分钟`)
33
+ const lines = [`=== 开发操作审计日志 (条件: ${fl.length > 0 ? fl.join(", ") : "无过滤条件(最近全部)"}) ===`, `共 ${logs.length} 条记录`]
34
+ for (let i = 0; i < logs.length; i++) {
35
+ const l = logs[i]; lines.push(`[${i+1}] ${l.createdAt.toISOString()}`, ` action: ${l.action} | targetType: ${l.targetType} | targetId: ${l.targetId || "(无)"}`)
36
+ if (l.reason) lines.push(` reason: ${l.reason}`); if (l.planKeyword) lines.push(` planKeyword: ${l.planKeyword}`)
37
+ }
38
+ if (planKeyword) { lines.push("=== Plan 分组 ===", `planKeyword=${planKeyword} 的操作链:`); for (const l of logs) lines.push(` ${l.createdAt.toISOString().slice(11,19)} ${l.action}`) }
39
+ lines.push("", "=== 稀疏推理建议 ===", "基于以上审计日志,可以恢复开发上下文。")
40
+ return textResponse(lines.join("\n"))
41
+ } catch (error) { return errorResponse(`查询审计日志失败: ${error instanceof Error ? error.message : String(error)}\n可能原因: 数据库未运行或 AuditLog 表不存在。`) }
42
+ })
43
+
44
+ // ===== record_dev_operation (L1111-1212) =====
45
+ server.registerTool("record_dev_operation", {
46
+ description: "记录一次开发操作到 DevOperation 表(ADD-7)。AI 助手在对代码进行任何修改/创建/删除操作后,必须调用此工具记录操作审计。\n\n**targetId 路径格式(强制)**:必须使用相对于 workspace 根目录的路径,禁止绝对路径。\n- ✅ src/middleware.ts\n- ✅ ${MAGIC_DIR}/plans/xxx.md\n- ❌ /absolute/path/to/src/middleware.ts",
47
+ inputSchema: z.object({
48
+ action: z.string().describe("操作类型: 'MODIFY', 'CREATE', 'DELETE', 'DOC_UPDATED', 'DOC_CREATED' 等"),
49
+ targetType: z.string().describe("目标类型: 'API_ROUTE', 'COMPONENT', 'SCHEMA', 'RULE', 'DOC', 'PLAN', 'SPEC', 'SKILL', 'AGENT' 等"),
50
+ targetId: z.string().optional().describe("目标标识(相对路径)"),
51
+ planKeyword: z.string().optional().describe("关联 Plan 的关键词"),
52
+ beforeState: z.string().optional().describe("操作前的状态(JSON 字符串)"),
53
+ afterState: z.string().optional().describe("操作后的状态(JSON 字符串)"),
54
+ reason: z.string().optional().describe("操作原因"),
55
+ }),
56
+ }, async (args: Record<string, string | number | undefined>, _ctx: unknown) => {
57
+ try {
58
+ const { action, targetType, targetId, planKeyword, beforeState, afterState, reason } = args
59
+ const pathWarnings: string[] = []
60
+ if (targetId && (targetId.startsWith("/") || /^[A-Z]:\\/.test(targetId))) pathWarnings.push(`⚠️ targetId 使用了绝对路径: "${targetId}"。请使用相对路径。`)
61
+ let parsedBefore: unknown, parsedAfter: unknown
62
+ try { if (beforeState) parsedBefore = JSON.parse(beforeState); if (afterState) parsedAfter = JSON.parse(afterState) } catch { return errorResponse("beforeState/afterState 必须是有效的 JSON 字符串。") }
63
+ let systemUser = await (prisma.addUser as Record<string, (...a: unknown[]) => unknown>).findUnique({ where: { username: "ai-assistant" }, select: { id: true } })
64
+ if (!systemUser) systemUser = await (prisma.addUser as Record<string, (...a: unknown[]) => unknown>).create({ data: { id: "ai-assistant", username: "ai-assistant", email: "ai-assistant@internal" }, select: { id: true } })
65
+ const log = await (prisma.devOperation as Record<string, (...a: unknown[]) => unknown>).create({ data: { userId: systemUser.id, planKeyword: planKeyword || "unknown", action, targetType, targetId: targetId || "unknown", beforeState: parsedBefore ?? null, afterState: parsedAfter ?? null, reason: reason || null } })
66
+ const lines = [`✅ 开发操作已记录`, ` ID: ${log.id}`, ` action: ${action}`, ` targetType: ${targetType}`, ` targetId: ${targetId || "unknown"}`, ` planKeyword: ${planKeyword || "unknown"}`, ` createdAt: ${log.createdAt.toISOString()}`]
67
+ if (pathWarnings.length > 0) { lines.push(""); lines.push(...pathWarnings) }
68
+ lines.push("", `📋 落库回查(必须执行):`, targetId ? ` query_audit_logs({ targetId: "${targetId}" }) — 确认本条记录已写入` : "")
69
+ return textResponse(lines.join("\n"))
70
+ } catch (error) { return errorResponse(`记录开发操作失败: ${error instanceof Error ? error.message : String(error)}`) }
71
+ })
72
+
73
+ }