add-coder 0.2.7 → 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.
- package/README.en.md +23 -4
- package/README.md +77 -20
- package/dist/index.js +10 -0
- package/package.json +4 -11
- package/templates/adapters/claude/hooks/doc-format-guard.sh +17 -0
- package/templates/adapters/claude/hooks/lib/notify.sh +34 -0
- package/templates/adapters/claude/hooks/pre-tool-use.sh +26 -7
- package/templates/adapters/claude/hooks/prompt-submit.sh +16 -0
- package/templates/adapters/codex/hooks/doc-format-guard.sh +17 -0
- package/templates/adapters/codex/hooks/lib/notify.sh +34 -0
- package/templates/adapters/codex/hooks/pre-tool-use.sh +48 -18
- package/templates/adapters/codex/hooks/prompt-submit.sh +16 -0
- package/templates/adapters/qoder/hooks/doc-format-guard.sh +17 -0
- package/templates/adapters/qoder/hooks/lib/notify.sh +34 -0
- package/templates/adapters/qoder/hooks/pre-tool-use.sh +28 -9
- package/templates/adapters/qoder/hooks/prompt-submit.sh +18 -1
- package/templates/adapters/trae/hooks/doc-format-guard.sh +17 -0
- package/templates/adapters/trae/hooks/lib/notify.sh +34 -0
- package/templates/adapters/trae/hooks/pre-tool-use.sh +48 -18
- package/templates/adapters/trae/hooks/prompt-submit.sh +16 -0
- package/templates/adapters/vscode/hooks/doc-format-guard.sh +16 -0
- package/templates/adapters/vscode/hooks/lib/notify.sh +34 -0
- package/templates/adapters/vscode/hooks/pre-tool-use.sh +26 -7
- package/templates/adapters/vscode/hooks/prompt-submit.sh +16 -0
- package/templates/core/hooks/doc-format-guard.sh +17 -0
- package/templates/core/hooks/lib/notify.sh +34 -0
- package/templates/core/hooks/pre-tool-use.sh +48 -18
- package/templates/core/hooks/prompt-submit.sh +16 -0
- package/templates/core/reviews/.gitkeep +0 -0
- package/templates/core/scripts/mcp-server/elicitation/confirm.ts +30 -0
- package/templates/core/scripts/mcp-server/elicitation/index.ts +1 -0
- package/templates/core/scripts/mcp-server/index.ts +15 -0
- package/templates/core/scripts/mcp-server/notifications/hitl.ts +49 -0
- package/templates/core/scripts/mcp-server/notifications/hook.ts +268 -0
- package/templates/core/scripts/mcp-server/notifications/index.ts +8 -0
- package/templates/core/scripts/mcp-server/resources/add-coder-version.ts +25 -0
- package/templates/core/scripts/mcp-server/resources/add-state.ts +44 -0
- package/templates/core/scripts/mcp-server/resources/hook-events-report.ts +104 -0
- package/templates/core/scripts/mcp-server/resources/index.ts +12 -0
- package/templates/core/scripts/mcp-server/resources/round-task.ts +22 -0
- package/templates/core/scripts/mcp-server/sampling/index.ts +1 -0
- package/templates/core/scripts/mcp-server/sampling/review.ts +47 -0
- package/templates/core/scripts/mcp-server/shared/env.ts +26 -0
- package/templates/core/scripts/mcp-server/shared/fs.ts +40 -0
- package/templates/core/scripts/mcp-server/shared/prisma.ts +35 -0
- package/templates/core/scripts/mcp-server/shared/response.ts +9 -0
- package/templates/core/scripts/mcp-server/tasks/index.ts +2 -0
- package/templates/core/scripts/mcp-server/tasks/runner.ts +20 -0
- package/templates/core/scripts/mcp-server/tasks/store.ts +20 -0
- package/templates/core/scripts/mcp-server/tools/audit.ts +73 -0
- package/templates/core/scripts/mcp-server/tools/context.ts +337 -0
- package/templates/core/scripts/mcp-server/tools/docs.ts +42 -0
- package/templates/core/scripts/mcp-server/tools/gateway.ts +135 -0
- package/templates/core/scripts/mcp-server/tools/hook-event-report.ts +92 -0
- package/templates/core/scripts/mcp-server/tools/index.ts +17 -0
- package/templates/core/scripts/mcp-server/tools/quality.ts +93 -0
- package/templates/core/scripts/mcp-server/types.ts +7 -0
- package/templates/core/scripts/mcp-server.ts +8 -3455
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { existsSync, watch, readFileSync, statSync } from "fs"
|
|
4
|
+
import { readFile } from "fs/promises"
|
|
5
|
+
import { PROJECT_ROOT, MAGIC_DIR } from "../shared/fs.js"
|
|
6
|
+
import { prisma } from "../shared/prisma.js"
|
|
7
|
+
|
|
8
|
+
// ── 类型定义 ──
|
|
9
|
+
interface HookEvent {
|
|
10
|
+
ts: string
|
|
11
|
+
hook: string
|
|
12
|
+
decision: string
|
|
13
|
+
cmd: string
|
|
14
|
+
reason: string
|
|
15
|
+
planKeyword: string
|
|
16
|
+
planStatus: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface QueueState {
|
|
20
|
+
events: HookEvent[]
|
|
21
|
+
/** 文件 inode(检测轮转) */
|
|
22
|
+
inode: number
|
|
23
|
+
/** 文件已读字节数 */
|
|
24
|
+
bytesRead: number
|
|
25
|
+
/** flush 定时器 */
|
|
26
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const REPORT_DIR = join(PROJECT_ROOT, MAGIC_DIR, "reports")
|
|
30
|
+
const JSONL_FILE = join(REPORT_DIR, "hook-events.jsonl")
|
|
31
|
+
const OVERFLOW_FILE = join(REPORT_DIR, "hook-events-overflow.jsonl")
|
|
32
|
+
|
|
33
|
+
// ── 内存缓冲队列 ──
|
|
34
|
+
const MAX_QUEUE = 50
|
|
35
|
+
let serverRef: McpServer | null = null
|
|
36
|
+
|
|
37
|
+
function createQueueState(): QueueState {
|
|
38
|
+
const st = statSync(JSONL_FILE, { throwIfNoEntry: false })
|
|
39
|
+
return {
|
|
40
|
+
events: [],
|
|
41
|
+
inode: st?.ino ?? 0,
|
|
42
|
+
bytesRead: st?.size ?? 0,
|
|
43
|
+
timer: null,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const q: QueueState = createQueueState()
|
|
48
|
+
|
|
49
|
+
// ── jsonl 解析 ──
|
|
50
|
+
function parseJsonlLine(line: string): HookEvent | null {
|
|
51
|
+
const trimmed = line.trim()
|
|
52
|
+
if (!trimmed) return null
|
|
53
|
+
try {
|
|
54
|
+
const obj = JSON.parse(trimmed) as HookEvent
|
|
55
|
+
if (!obj.ts || !obj.hook) return null
|
|
56
|
+
return obj
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── 去重 key ──
|
|
63
|
+
function dedupKey(e: HookEvent): string {
|
|
64
|
+
return `${e.hook}::${e.ts}::${e.planKeyword}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── 批量落库 ──
|
|
68
|
+
async function flushToDB(events: HookEvent[]): Promise<number> {
|
|
69
|
+
if (events.length === 0) return 0
|
|
70
|
+
const seen = new Set<string>()
|
|
71
|
+
const unique = events.filter(e => {
|
|
72
|
+
const k = dedupKey(e)
|
|
73
|
+
if (seen.has(k)) return false
|
|
74
|
+
seen.add(k)
|
|
75
|
+
return true
|
|
76
|
+
})
|
|
77
|
+
if (unique.length === 0) return 0
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// 获取或创建 ai-assistant 用户
|
|
81
|
+
const au = prisma.addUser as Record<string, (...a: unknown[]) => unknown>
|
|
82
|
+
let userId: string
|
|
83
|
+
try {
|
|
84
|
+
const existing = await au.findUnique({ where: { username: "ai-assistant" }, select: { id: true } }) as { id: string } | null
|
|
85
|
+
if (existing) {
|
|
86
|
+
userId = existing.id
|
|
87
|
+
} else {
|
|
88
|
+
const created = await au.create({
|
|
89
|
+
data: { id: "ai-assistant", username: "ai-assistant", email: "ai-assistant@internal" },
|
|
90
|
+
select: { id: true },
|
|
91
|
+
}) as { id: string }
|
|
92
|
+
userId = created.id
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
userId = "ai-assistant"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ops = prisma.devOperation as Record<string, (...a: unknown[]) => unknown>
|
|
99
|
+
const data = unique.map(e => ({
|
|
100
|
+
userId,
|
|
101
|
+
action: "HOOK_INTERCEPT",
|
|
102
|
+
targetType: e.hook,
|
|
103
|
+
targetId: e.cmd.substring(0, 500),
|
|
104
|
+
planKeyword: e.planKeyword,
|
|
105
|
+
reason: `[${e.decision}] ${e.reason}`,
|
|
106
|
+
afterState: JSON.stringify({ planStatus: e.planStatus }),
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
await ops.createMany({ data })
|
|
110
|
+
return unique.length
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error("[hook-notify] flushToDB 失败:", err instanceof Error ? err.message : String(err))
|
|
113
|
+
return 0
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── 消费溢出文件 ──
|
|
118
|
+
async function drainOverflowFile(): Promise<HookEvent[]> {
|
|
119
|
+
const events: HookEvent[] = []
|
|
120
|
+
try {
|
|
121
|
+
if (!existsSync(OVERFLOW_FILE)) return events
|
|
122
|
+
const content = await readFile(OVERFLOW_FILE, "utf-8")
|
|
123
|
+
for (const line of content.split("\n")) {
|
|
124
|
+
const ev = parseJsonlLine(line)
|
|
125
|
+
if (ev) events.push(ev)
|
|
126
|
+
}
|
|
127
|
+
// 清空溢出文件
|
|
128
|
+
const { writeFileSync } = await import("fs")
|
|
129
|
+
writeFileSync(OVERFLOW_FILE, "", "utf-8")
|
|
130
|
+
} catch {
|
|
131
|
+
// 忽略,下次 flush 重试
|
|
132
|
+
}
|
|
133
|
+
return events
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── 核心: flush ──
|
|
137
|
+
async function doFlush(): Promise<void> {
|
|
138
|
+
// 1) drain 内存队列
|
|
139
|
+
const memEvents = q.events.splice(0)
|
|
140
|
+
// 2) drain 溢出文件
|
|
141
|
+
const overflowEvents = await drainOverflowFile()
|
|
142
|
+
// 3) 合并
|
|
143
|
+
const allEvents = [...memEvents, ...overflowEvents]
|
|
144
|
+
if (allEvents.length === 0) return
|
|
145
|
+
|
|
146
|
+
const count = await flushToDB(allEvents)
|
|
147
|
+
if (count > 0 && serverRef) {
|
|
148
|
+
const noPlanCount = allEvents.filter(e => e.planKeyword === "no-active-plan").length
|
|
149
|
+
const msg = `[Hook] ${count} 条拦截事件已审计落库(计划: ${[...new Set(allEvents.map(e => e.planKeyword))].join(", ")})`
|
|
150
|
+
serverRef.sendLoggingMessage({ level: "warning" as const, data: msg }).catch(() => {})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── 入队 + 触发策略 ──
|
|
155
|
+
function enqueue(event: HookEvent): void {
|
|
156
|
+
if (q.events.length >= MAX_QUEUE) {
|
|
157
|
+
// 降级写入溢出文件
|
|
158
|
+
try {
|
|
159
|
+
const { appendFileSync } = require("fs") as typeof import("fs")
|
|
160
|
+
appendFileSync(OVERFLOW_FILE, JSON.stringify(event) + "\n", "utf-8")
|
|
161
|
+
} catch { /* 静默 */ }
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
q.events.push(event)
|
|
165
|
+
|
|
166
|
+
if (q.events.length >= MAX_QUEUE) {
|
|
167
|
+
// 满 50 条立即 flush
|
|
168
|
+
if (q.timer) { clearTimeout(q.timer); q.timer = null }
|
|
169
|
+
void doFlush()
|
|
170
|
+
} else if (!q.timer) {
|
|
171
|
+
// 调度 2s 后 flush
|
|
172
|
+
q.timer = setTimeout(() => {
|
|
173
|
+
q.timer = null
|
|
174
|
+
void doFlush()
|
|
175
|
+
}, 2000)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── fs.watch 回调 ──
|
|
180
|
+
function readNewLines(): void {
|
|
181
|
+
try {
|
|
182
|
+
const st = statSync(JSONL_FILE, { throwIfNoEntry: false })
|
|
183
|
+
if (!st) return
|
|
184
|
+
|
|
185
|
+
// 检测文件轮转(inode 变化或文件变小)
|
|
186
|
+
if (st.ino !== q.inode || st.size < q.bytesRead) {
|
|
187
|
+
q.inode = st.ino
|
|
188
|
+
q.bytesRead = 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (st.size <= q.bytesRead) return
|
|
192
|
+
|
|
193
|
+
// 只读增量部分
|
|
194
|
+
const fd = require("fs").openSync(JSONL_FILE, "r")
|
|
195
|
+
const buf = Buffer.alloc(st.size - q.bytesRead)
|
|
196
|
+
require("fs").readSync(fd, buf, 0, buf.length, q.bytesRead)
|
|
197
|
+
require("fs").closeSync(fd)
|
|
198
|
+
|
|
199
|
+
const text = buf.toString("utf-8")
|
|
200
|
+
for (const line of text.split("\n")) {
|
|
201
|
+
const ev = parseJsonlLine(line)
|
|
202
|
+
if (ev) enqueue(ev)
|
|
203
|
+
}
|
|
204
|
+
q.bytesRead = st.size
|
|
205
|
+
} catch {
|
|
206
|
+
// 文件可能被轮转删除,下次重试
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── 启动时全量扫描已有文件 ──
|
|
211
|
+
async function initialScan(): Promise<void> {
|
|
212
|
+
const files = [JSONL_FILE, `${JSONL_FILE}.old`]
|
|
213
|
+
for (const f of files) {
|
|
214
|
+
if (!existsSync(f)) continue
|
|
215
|
+
try {
|
|
216
|
+
const content = await readFile(f, "utf-8")
|
|
217
|
+
for (const line of content.split("\n")) {
|
|
218
|
+
const ev = parseJsonlLine(line)
|
|
219
|
+
if (ev) enqueue(ev)
|
|
220
|
+
}
|
|
221
|
+
} catch { /* skip */ }
|
|
222
|
+
}
|
|
223
|
+
// 初始化消费位点
|
|
224
|
+
const st = statSync(JSONL_FILE, { throwIfNoEntry: false })
|
|
225
|
+
if (st) {
|
|
226
|
+
q.inode = st.ino
|
|
227
|
+
q.bytesRead = st.size
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── 注册 ──
|
|
232
|
+
export function registerHookNotifications(server: McpServer) {
|
|
233
|
+
serverRef = server
|
|
234
|
+
|
|
235
|
+
// 启动通知
|
|
236
|
+
server.sendLoggingMessage({
|
|
237
|
+
level: "notice" as const,
|
|
238
|
+
data: "ADD Hooks 治理卡位已激活,拦截事件将自动审计落库"
|
|
239
|
+
}).catch(() => {})
|
|
240
|
+
|
|
241
|
+
// 启动时扫描 + 建立 fs.watch
|
|
242
|
+
void initialScan().then(() => {
|
|
243
|
+
// 确保 jsonl 文件存在(fs.watch 要求文件已存在)
|
|
244
|
+
try {
|
|
245
|
+
const { mkdirSync, writeFileSync } = require("fs") as typeof import("fs")
|
|
246
|
+
mkdirSync(REPORT_DIR, { recursive: true })
|
|
247
|
+
if (!existsSync(JSONL_FILE)) writeFileSync(JSONL_FILE, "", "utf-8")
|
|
248
|
+
} catch { /* ok */ }
|
|
249
|
+
|
|
250
|
+
// 立即消费启动前已有事件
|
|
251
|
+
void doFlush()
|
|
252
|
+
|
|
253
|
+
// fs.watch 监听目录变化(文件可能后来才创建)
|
|
254
|
+
try {
|
|
255
|
+
watch(REPORT_DIR, (_event, filename) => {
|
|
256
|
+
if (filename === "hook-events.jsonl" || filename === "hook-events.jsonl.old") {
|
|
257
|
+
readNewLines()
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
} catch { /* watch 可能不支持 */ }
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// 进程退出时清空剩余队列
|
|
264
|
+
const cleanup = () => { void doFlush() }
|
|
265
|
+
process.on("exit", cleanup)
|
|
266
|
+
process.on("SIGTERM", cleanup)
|
|
267
|
+
process.on("SIGINT", cleanup)
|
|
268
|
+
}
|
|
@@ -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
|
+
}
|