@luckychen1993/pm-mcp 0.1.3 → 0.1.4
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.md +8 -6
- package/dist/audit-tools.js +8 -7
- package/dist/audit.js +10 -5
- package/dist/idempotency.js +201 -67
- package/dist/index-store.js +163 -94
- package/dist/index.js +22 -17
- package/dist/license.js +7 -4
- package/dist/paths.js +35 -0
- package/dist/search.js +2 -2
- package/dist/security.js +15 -6
- package/dist/setup.js +1 -1
- package/dist/store.js +175 -58
- package/dist/tool-base.js +4 -4
- package/dist/watcher-coordinator.js +182 -0
- package/package.json +1 -1
package/dist/index-store.js
CHANGED
|
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { isInitialized } from "./paths.js";
|
|
5
5
|
import { normSep } from "./budget.js";
|
|
6
6
|
import { getIndex, getMeta, setMeta } from "./index-db.js";
|
|
7
|
+
import { clearWatcherDirty, coordinateWatcherLeader, markWatcherDirty, watcherIsClean } from "./watcher-coordinator.js";
|
|
7
8
|
export { aggregates, closeIndex, getIndex, getMeta, iterateFileRows, setMeta } from "./index-db.js";
|
|
8
9
|
import { BINARY_EXTS, countLoc, isLockfile, isTestFile, extOf, countSkipLines, needsSkipCount, looksTrivialTest, DEFAULT_IGNORE_DIRS, walkStatEntries, } from "./scan.js";
|
|
9
10
|
/**
|
|
@@ -160,7 +161,10 @@ export function walkRefresh(root, opts = {}) {
|
|
|
160
161
|
const watcherSession = getMeta(db, "watcherSession");
|
|
161
162
|
if (watcherSession !== null)
|
|
162
163
|
setMeta(db, "lastWalkSession", watcherSession);
|
|
164
|
+
setMeta(db, "watcherError", "");
|
|
163
165
|
}
|
|
166
|
+
clearWatcherDirty(root);
|
|
167
|
+
activeWatchers.get(path.resolve(root))?.reconciled();
|
|
164
168
|
return { totalFiles: total, changed: processed, deleted: Number(del.changes ?? 0), hits: total - processed, skippedDeep };
|
|
165
169
|
}
|
|
166
170
|
const BEAT_STALE_MS = 90_000;
|
|
@@ -178,6 +182,8 @@ export function freshness(root) {
|
|
|
178
182
|
const eventAge = lastEvent ? Date.now() - Date.parse(lastEvent) : Number.POSITIVE_INFINITY;
|
|
179
183
|
const watcherSession = getMeta(db, "watcherSession");
|
|
180
184
|
const sessionReconciled = watcherSession !== null && getMeta(db, "lastWalkSession") === watcherSession;
|
|
185
|
+
const watcherHealthy = !(getMeta(db, "watcherError") ?? "");
|
|
186
|
+
const watcherClean = watcherIsClean(root);
|
|
181
187
|
// 卡死计数自愈:防抖 150ms,若计数>0 但 30s 无任何事件推进,说明计数器失真(如持锁期抛错),不再信任
|
|
182
188
|
const pendingClean = pending === 0 || eventAge > 30_000;
|
|
183
189
|
const fresh = files > 0 &&
|
|
@@ -185,6 +191,8 @@ export function freshness(root) {
|
|
|
185
191
|
owned &&
|
|
186
192
|
mode === "watcher" &&
|
|
187
193
|
sessionReconciled &&
|
|
194
|
+
watcherHealthy &&
|
|
195
|
+
watcherClean &&
|
|
188
196
|
pendingClean &&
|
|
189
197
|
beatAge < BEAT_STALE_MS;
|
|
190
198
|
return { mode, fresh, files, lastWalk, lastBeat, pendingEvents: pending };
|
|
@@ -232,29 +240,71 @@ export function drainWatcher(root) {
|
|
|
232
240
|
*/
|
|
233
241
|
export function startWatcher(root) {
|
|
234
242
|
const abs = path.resolve(root);
|
|
235
|
-
|
|
243
|
+
let db = null;
|
|
236
244
|
let pending = 0;
|
|
237
245
|
const timers = new Map();
|
|
238
|
-
let
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
246
|
+
let watcher = null;
|
|
247
|
+
let beat = null;
|
|
248
|
+
let leader = false;
|
|
249
|
+
let stopped = false;
|
|
250
|
+
let locallyUnhealthy = false;
|
|
251
|
+
const active = {
|
|
252
|
+
timers,
|
|
253
|
+
closed: () => stopped || !leader,
|
|
254
|
+
reconciled: () => { locallyUnhealthy = false; },
|
|
255
|
+
};
|
|
256
|
+
const ignored = (rel) => DEFAULT_IGNORE_DIRS.has(rel.split("/")[0]) || rel.split("/")[0] === ".pm";
|
|
257
|
+
const recordWatcherError = (stage, error) => {
|
|
258
|
+
locallyUnhealthy = true;
|
|
259
|
+
try {
|
|
260
|
+
markWatcherDirty(abs);
|
|
261
|
+
}
|
|
262
|
+
catch (markerError) {
|
|
263
|
+
console.error(`[pm-mcp] watcher dirty marker failed: ${markerError instanceof Error ? markerError.message : String(markerError)}`);
|
|
264
|
+
}
|
|
265
|
+
console.error(`[pm-mcp] watcher ${stage} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
266
|
+
if (!db)
|
|
267
|
+
return;
|
|
268
|
+
try {
|
|
269
|
+
setMeta(db, "watcherError", new Date().toISOString());
|
|
270
|
+
}
|
|
271
|
+
catch (metaError) {
|
|
272
|
+
console.error(`[pm-mcp] watcher error state persistence failed: ${metaError instanceof Error ? metaError.message : String(metaError)}`);
|
|
273
|
+
}
|
|
242
274
|
};
|
|
243
|
-
const bumpPending = (d) => {
|
|
275
|
+
const bumpPending = (d, allowClean = false) => {
|
|
244
276
|
pending = Math.max(0, pending + d);
|
|
245
277
|
try {
|
|
246
|
-
|
|
278
|
+
if (pending > 0)
|
|
279
|
+
markWatcherDirty(abs);
|
|
280
|
+
if (db)
|
|
281
|
+
setMeta(db, "pending", String(pending));
|
|
282
|
+
if (pending === 0 && allowClean && !locallyUnhealthy)
|
|
283
|
+
clearWatcherDirty(abs);
|
|
247
284
|
}
|
|
248
|
-
catch {
|
|
249
|
-
|
|
285
|
+
catch (error) {
|
|
286
|
+
recordWatcherError("persist pending", error);
|
|
250
287
|
}
|
|
251
288
|
};
|
|
252
289
|
const process = (rel) => {
|
|
253
|
-
if (
|
|
290
|
+
if (stopped || !leader || !db)
|
|
254
291
|
return;
|
|
292
|
+
let succeeded = false;
|
|
255
293
|
try {
|
|
256
294
|
const full = path.join(abs, ...rel.split("/"));
|
|
257
|
-
|
|
295
|
+
let st;
|
|
296
|
+
try {
|
|
297
|
+
st = fs.statSync(full);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (error.code !== "ENOENT")
|
|
301
|
+
throw error;
|
|
302
|
+
deleteFile(db, rel);
|
|
303
|
+
deleteSubtree(db, rel);
|
|
304
|
+
setMeta(db, "lastEvent", new Date().toISOString());
|
|
305
|
+
succeeded = true;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
258
308
|
if (st.isDirectory()) {
|
|
259
309
|
// 新目录:补扫该子树(走 walkStatEntries 的 include 限定)
|
|
260
310
|
for (const e of walkStatEntries(abs, { include: [rel + "/**"] }, () => undefined)) {
|
|
@@ -267,107 +317,126 @@ export function startWatcher(root) {
|
|
|
267
317
|
return;
|
|
268
318
|
upsertFile(abs, db, rel, st, computeEntry(abs, rel, st));
|
|
269
319
|
}
|
|
320
|
+
setMeta(db, "lastEvent", new Date().toISOString());
|
|
321
|
+
succeeded = true;
|
|
270
322
|
}
|
|
271
|
-
catch {
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
deleteSubtree(db, rel);
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
/* ignore */
|
|
279
|
-
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
// In particular, SQLITE_BUSY must never be reported as a successful
|
|
325
|
+
// watcher event. Mark the shared cache unhealthy; the next audit walks.
|
|
326
|
+
recordWatcherError(`event ${rel}`, error);
|
|
280
327
|
}
|
|
281
328
|
finally {
|
|
282
|
-
bumpPending(-1);
|
|
329
|
+
bumpPending(-1, succeeded);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const deactivateLeader = () => {
|
|
333
|
+
if (!leader)
|
|
334
|
+
return;
|
|
335
|
+
leader = false;
|
|
336
|
+
if (activeWatchers.get(abs) === active)
|
|
337
|
+
activeWatchers.delete(abs);
|
|
338
|
+
if (beat)
|
|
339
|
+
clearInterval(beat);
|
|
340
|
+
beat = null;
|
|
341
|
+
for (const entry of timers.values())
|
|
342
|
+
clearTimeout(entry.timer);
|
|
343
|
+
timers.clear();
|
|
344
|
+
pending = 0;
|
|
345
|
+
try {
|
|
346
|
+
watcher?.close();
|
|
347
|
+
}
|
|
348
|
+
catch { /* best effort */ }
|
|
349
|
+
watcher = null;
|
|
350
|
+
if (db) {
|
|
283
351
|
try {
|
|
284
|
-
setMeta(db, "
|
|
352
|
+
setMeta(db, "pending", "0");
|
|
353
|
+
setMeta(db, "mode", "walk");
|
|
285
354
|
}
|
|
286
|
-
catch {
|
|
287
|
-
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (!(error instanceof Error && /database is not open/i.test(error.message)))
|
|
357
|
+
recordWatcherError("demotion", error);
|
|
288
358
|
}
|
|
289
359
|
}
|
|
290
360
|
};
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
let rel;
|
|
297
|
-
try {
|
|
298
|
-
rel = normSep(String(filename));
|
|
299
|
-
if (ignored(rel))
|
|
361
|
+
const activateLeader = () => {
|
|
362
|
+
db = getIndex(abs);
|
|
363
|
+
try {
|
|
364
|
+
watcher = fs.watch(abs, { recursive: true }, (_event, filename) => {
|
|
365
|
+
if (stopped || !leader || !filename)
|
|
300
366
|
return;
|
|
301
|
-
|
|
302
|
-
catch {
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
// 先建定时器再计数:即使计数持久化撞锁抛错,定时器也已就位(否则计数永不归零)
|
|
306
|
-
const prev = timers.get(rel);
|
|
307
|
-
if (prev) {
|
|
308
|
-
clearTimeout(prev.timer);
|
|
309
|
-
bumpPending(-1);
|
|
310
|
-
}
|
|
311
|
-
bumpPending(1);
|
|
312
|
-
const run = () => {
|
|
313
|
-
timers.delete(rel);
|
|
367
|
+
let rel;
|
|
314
368
|
try {
|
|
315
|
-
|
|
369
|
+
rel = normSep(String(filename));
|
|
370
|
+
if (ignored(rel))
|
|
371
|
+
return;
|
|
316
372
|
}
|
|
317
373
|
catch {
|
|
318
|
-
|
|
374
|
+
return;
|
|
319
375
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return;
|
|
339
|
-
try {
|
|
340
|
-
setMeta(db, "lastBeat", new Date().toISOString());
|
|
341
|
-
}
|
|
342
|
-
catch {
|
|
343
|
-
/* ignore */
|
|
376
|
+
// 先建定时器再计数:即使计数持久化撞锁抛错,定时器也已就位(否则计数永不归零)
|
|
377
|
+
const prev = timers.get(rel);
|
|
378
|
+
if (prev) {
|
|
379
|
+
clearTimeout(prev.timer);
|
|
380
|
+
}
|
|
381
|
+
else
|
|
382
|
+
bumpPending(1);
|
|
383
|
+
const run = () => {
|
|
384
|
+
timers.delete(rel);
|
|
385
|
+
try {
|
|
386
|
+
process(rel);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
bumpPending(-1);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
timers.set(rel, { timer: setTimeout(run, 150), due: Date.now() + 150, run });
|
|
393
|
+
});
|
|
344
394
|
}
|
|
345
|
-
|
|
346
|
-
beat.unref();
|
|
347
|
-
// 立刻打一次心跳(避免刚启动的 90s 空窗被判不新鲜)
|
|
348
|
-
setMeta(db, "lastBeat", new Date().toISOString());
|
|
349
|
-
return {
|
|
350
|
-
stop() {
|
|
351
|
-
stopped = true;
|
|
352
|
-
activeWatchers.delete(abs);
|
|
353
|
-
closed = true;
|
|
354
|
-
clearInterval(beat);
|
|
355
|
-
for (const t of timers.values())
|
|
356
|
-
clearTimeout(t.timer);
|
|
357
|
-
try {
|
|
358
|
-
watcher.close();
|
|
359
|
-
}
|
|
360
|
-
catch {
|
|
361
|
-
/* ignore */
|
|
362
|
-
}
|
|
395
|
+
catch (error) {
|
|
363
396
|
try {
|
|
364
397
|
setMeta(db, "mode", "walk");
|
|
365
398
|
}
|
|
366
|
-
catch {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
399
|
+
catch { /* freshness also requires a live heartbeat */ }
|
|
400
|
+
recordWatcherError("activation", error);
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
watcher.unref();
|
|
405
|
+
leader = true;
|
|
406
|
+
// 每位 leader 都换一个会话代次。强杀/接管之间的停机窗口必须先精确走查。
|
|
407
|
+
setMeta(db, "watcherSession", randomUUID());
|
|
408
|
+
setMeta(db, "pending", "0");
|
|
409
|
+
setMeta(db, "watcherError", "");
|
|
410
|
+
setMeta(db, "mode", "watcher");
|
|
411
|
+
setMeta(db, "lastBeat", new Date().toISOString());
|
|
412
|
+
activeWatchers.set(abs, active);
|
|
413
|
+
beat = setInterval(() => {
|
|
414
|
+
if (stopped || !leader || !db)
|
|
415
|
+
return;
|
|
416
|
+
try {
|
|
417
|
+
setMeta(db, "lastBeat", new Date().toISOString());
|
|
418
|
+
if (!(getMeta(db, "watcherError") ?? "") && watcherIsClean(abs))
|
|
419
|
+
locallyUnhealthy = false;
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
recordWatcherError("heartbeat", error);
|
|
423
|
+
}
|
|
424
|
+
}, 10_000);
|
|
425
|
+
beat.unref();
|
|
426
|
+
return deactivateLeader;
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
deactivateLeader();
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
370
432
|
};
|
|
433
|
+
const coordinator = coordinateWatcherLeader(abs, activateLeader, recordWatcherError);
|
|
434
|
+
if (!coordinator)
|
|
435
|
+
return null;
|
|
436
|
+
return { stop() { if (!stopped) {
|
|
437
|
+
stopped = true;
|
|
438
|
+
coordinator.stop();
|
|
439
|
+
} } };
|
|
371
440
|
}
|
|
372
441
|
/** 巡检/诊断用:索引概况一行 */
|
|
373
442
|
export function indexSummary(root) {
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { resolveRoot, isInitialized } from "./paths.js";
|
|
4
|
+
import { resolveRoot, isInitialized, ensurePmRuntimeIgnored } from "./paths.js";
|
|
5
5
|
import { registerAllTools } from "./tools.js";
|
|
6
6
|
import { loadRoadmap, loadTasks } from "./store.js";
|
|
7
7
|
import { renderRoadmap } from "./roadmap.js";
|
|
@@ -14,6 +14,7 @@ import fs from "node:fs";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { listAcceptanceBaselines } from "./acceptance-tools.js";
|
|
16
16
|
import { AcceptanceReportSchema } from "./acceptance-report.js";
|
|
17
|
+
import { runCoalescedRead } from "./idempotency.js";
|
|
17
18
|
/* --root 参数 > PM_ROOT 环境变量 > 启动时工作目录 */
|
|
18
19
|
const argv = process.argv.slice(2);
|
|
19
20
|
let explicitRoot;
|
|
@@ -22,7 +23,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
22
23
|
explicitRoot = argv[i + 1];
|
|
23
24
|
}
|
|
24
25
|
const root = resolveRoot(explicitRoot);
|
|
25
|
-
const server = new McpServer({ name: "pm-mcp", version: "0.1.
|
|
26
|
+
const server = new McpServer({ name: "pm-mcp", version: "0.1.4" }, {
|
|
26
27
|
instructions: [
|
|
27
28
|
"多 Agent 规则:相同业务的所有写工具调用必须携带相同 idempotency_key(建议 task-id:operation);同键同参数只执行一次,同键不同参数会被拒绝。",
|
|
28
29
|
"完全相同的并行读请求会由服务端合并并复用结果;不要让多个分支重复请求同一工具和参数。",
|
|
@@ -39,30 +40,33 @@ function safeText(fn) {
|
|
|
39
40
|
return `(不可用: ${e.message})`;
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
+
async function resourceText(name, fn) {
|
|
44
|
+
return (await runCoalescedRead(root, `resource:${name}`, {}, () => safeText(fn))).text;
|
|
45
|
+
}
|
|
46
|
+
server.registerResource("dashboard", "pm://dashboard", { description: "项目仪表盘(与仓库根 PROJECT.md 同源),路线图/健康摘要/任务/功能清单" }, async (uri) => ({
|
|
43
47
|
contents: [
|
|
44
48
|
{
|
|
45
49
|
uri: uri.href,
|
|
46
50
|
mimeType: "text/markdown",
|
|
47
|
-
text:
|
|
51
|
+
text: await resourceText("dashboard", () => fs.readFileSync(dashboardFile(root), "utf8")),
|
|
48
52
|
},
|
|
49
53
|
],
|
|
50
54
|
}));
|
|
51
|
-
server.registerResource("roadmap", "pm://roadmap", { description: "路线图(里程碑进度 + 活跃里程碑任务明细)" }, (uri) => ({
|
|
55
|
+
server.registerResource("roadmap", "pm://roadmap", { description: "路线图(里程碑进度 + 活跃里程碑任务明细)" }, async (uri) => ({
|
|
52
56
|
contents: [
|
|
53
57
|
{
|
|
54
58
|
uri: uri.href,
|
|
55
59
|
mimeType: "text/markdown",
|
|
56
|
-
text:
|
|
60
|
+
text: await resourceText("roadmap", () => renderRoadmap(loadRoadmap(root).milestones, loadTasks(root).tasks, 2).join("\n")),
|
|
57
61
|
},
|
|
58
62
|
],
|
|
59
63
|
}));
|
|
60
|
-
server.registerResource("tasks", "pm://tasks", { description: "任务清单原始数据(tasks.json;超 100 条截断,完整数据用 list_tasks 过滤)" }, (uri) => ({
|
|
64
|
+
server.registerResource("tasks", "pm://tasks", { description: "任务清单原始数据(tasks.json;超 100 条截断,完整数据用 list_tasks 过滤)" }, async (uri) => ({
|
|
61
65
|
contents: [
|
|
62
66
|
{
|
|
63
67
|
uri: uri.href,
|
|
64
68
|
mimeType: "application/json",
|
|
65
|
-
text:
|
|
69
|
+
text: await resourceText("tasks", () => {
|
|
66
70
|
const tasks = loadTasks(root).tasks;
|
|
67
71
|
// 资源也要守 token 预算:全量 dump 大账本会吃爆上下文(截断后仍是合法 JSON)
|
|
68
72
|
if (tasks.length <= 100)
|
|
@@ -77,30 +81,30 @@ server.registerResource("tasks", "pm://tasks", { description: "任务清单原
|
|
|
77
81
|
},
|
|
78
82
|
],
|
|
79
83
|
}));
|
|
80
|
-
server.registerResource("changelog", "pm://changelog", { description: "变更日志(由 sessions.json 生成的人类可读版本)" }, (uri) => ({
|
|
84
|
+
server.registerResource("changelog", "pm://changelog", { description: "变更日志(由 sessions.json 生成的人类可读版本)" }, async (uri) => ({
|
|
81
85
|
contents: [
|
|
82
86
|
{
|
|
83
87
|
uri: uri.href,
|
|
84
88
|
mimeType: "text/markdown",
|
|
85
|
-
text:
|
|
89
|
+
text: await resourceText("changelog", () => buildChangelog(root)),
|
|
86
90
|
},
|
|
87
91
|
],
|
|
88
92
|
}));
|
|
89
|
-
server.registerResource("architecture", "pm://architecture", { description: "跨文件/模块/语言治理审计:语义覆盖、依赖边界、接口、循环与质量矩阵" }, (uri) => ({
|
|
90
|
-
contents: [{ uri: uri.href, mimeType: "text/markdown", text:
|
|
93
|
+
server.registerResource("architecture", "pm://architecture", { description: "跨文件/模块/语言治理审计:语义覆盖、依赖边界、接口、循环与质量矩阵" }, async (uri) => ({
|
|
94
|
+
contents: [{ uri: uri.href, mimeType: "text/markdown", text: await resourceText("architecture", () => auditGovernance(root, 150).report) }],
|
|
91
95
|
}));
|
|
92
|
-
server.registerResource("portfolio", "pm://portfolio", { description: "当前项目的组合视图与跨仓依赖/版本风险" }, (uri) => ({
|
|
96
|
+
server.registerResource("portfolio", "pm://portfolio", { description: "当前项目的组合视图与跨仓依赖/版本风险" }, async (uri) => ({
|
|
93
97
|
contents: [{
|
|
94
98
|
uri: uri.href,
|
|
95
99
|
mimeType: "application/json",
|
|
96
|
-
text:
|
|
100
|
+
text: await resourceText("portfolio", () => JSON.stringify(buildPortfolioReport({ projects: [loadPortfolioProject(root)], projectsRequested: 1 }), null, 2)),
|
|
97
101
|
}],
|
|
98
102
|
}));
|
|
99
|
-
server.registerResource("acceptance", "pm://acceptance", { description: "标准化产品验收概览:版本化预批准基线与最近一次机器判定报告" }, (uri) => ({
|
|
103
|
+
server.registerResource("acceptance", "pm://acceptance", { description: "标准化产品验收概览:版本化预批准基线与最近一次机器判定报告" }, async (uri) => ({
|
|
100
104
|
contents: [{
|
|
101
105
|
uri: uri.href,
|
|
102
106
|
mimeType: "application/json",
|
|
103
|
-
text:
|
|
107
|
+
text: await resourceText("acceptance", () => {
|
|
104
108
|
const reportDir = pmPath(root, "acceptance", "reports");
|
|
105
109
|
let latestReport = null;
|
|
106
110
|
if (fs.existsSync(reportDir)) {
|
|
@@ -209,8 +213,9 @@ await server.connect(new StdioServerTransport());
|
|
|
209
213
|
// 超大项目核心:server 常驻期间用递归 watcher 持续保鲜 SQLite 索引,
|
|
210
214
|
// audit/快照稳态下免全量走查(失败自动降级为纯走查模式)
|
|
211
215
|
if (isInitialized(root)) {
|
|
216
|
+
ensurePmRuntimeIgnored(root);
|
|
212
217
|
const watcher = startWatcher(root);
|
|
213
|
-
console.error(`[pm-mcp] watcher: ${watcher ? "active" : "unavailable(降级为按需全量走查)"}`);
|
|
218
|
+
console.error(`[pm-mcp] watcher coordinator: ${watcher ? "active(同项目单 leader)" : "unavailable(降级为按需全量走查)"}`);
|
|
214
219
|
}
|
|
215
220
|
// stdio 是协议通道,日志只能走 stderr
|
|
216
221
|
console.error(`[pm-mcp] ready. project root: ${root}`);
|
package/dist/license.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { loadFileNotes, loadProject } from "./store.js";
|
|
4
4
|
import { isTestFile, readDirectDeps } from "./scan.js";
|
|
5
|
-
import { ensureFresh, getIndex, iterateFileRows } from "./index-store.js";
|
|
5
|
+
import { ensureFresh, getIndex, iterateFileRows, walkRefresh } from "./index-store.js";
|
|
6
6
|
import { readText } from "./search.js";
|
|
7
7
|
import { foldLines } from "./budget.js";
|
|
8
8
|
/**
|
|
@@ -130,7 +130,9 @@ function readNodeLicenses(root) {
|
|
|
130
130
|
return { list, truncated };
|
|
131
131
|
}
|
|
132
132
|
const GPL_HEADER = /GNU (LESSER |AFFERO )?GENERAL PUBLIC LICENSE/i;
|
|
133
|
-
export function auditLicense(root, maxLines = 150) {
|
|
133
|
+
export function auditLicense(root, maxLines = 150, forceFresh = false, indexPrepared = false) {
|
|
134
|
+
if (forceFresh)
|
|
135
|
+
walkRefresh(root, { forceContent: true });
|
|
134
136
|
const project = loadProject(root);
|
|
135
137
|
const rootPkg = path.join(root, "package.json");
|
|
136
138
|
let pkgLicense = "";
|
|
@@ -207,7 +209,8 @@ export function auditLicense(root, maxLines = 150) {
|
|
|
207
209
|
/* ③ 可疑许可证头 */
|
|
208
210
|
L.push("");
|
|
209
211
|
L.push("### ③ 源码中的 GPL 家族许可证头(复制带许可证代码的常见痕迹)");
|
|
210
|
-
|
|
212
|
+
if (!forceFresh && !indexPrepared)
|
|
213
|
+
ensureFresh(root);
|
|
211
214
|
const hits = [];
|
|
212
215
|
for (const row of iterateFileRows(getIndex(root))) {
|
|
213
216
|
const rel = row.rel;
|
|
@@ -216,7 +219,7 @@ export function auditLicense(root, maxLines = 150) {
|
|
|
216
219
|
// 测试文件常内嵌许可证字符串作为 fixture,不参与该启发式(信号针对混入源码的复制代码)
|
|
217
220
|
if (isTestFile(rel))
|
|
218
221
|
continue;
|
|
219
|
-
const content = readText(root, rel);
|
|
222
|
+
const content = readText(root, rel, forceFresh);
|
|
220
223
|
if (content === null)
|
|
221
224
|
continue;
|
|
222
225
|
if (GPL_HEADER.test(content.split("\n").slice(0, 40).join("\n")))
|
package/dist/paths.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
/** 项目根解析顺序:--root 参数 > PM_ROOT 环境变量 > 当前工作目录 */
|
|
5
6
|
export function resolveRoot(explicit) {
|
|
6
7
|
const raw = explicit?.trim() || process.env.PM_ROOT?.trim() || process.cwd();
|
|
@@ -48,4 +49,38 @@ export function ensurePmDirs(root) {
|
|
|
48
49
|
fs.mkdirSync(pmPath(root), { recursive: true });
|
|
49
50
|
fs.mkdirSync(decisionsDir(root), { recursive: true });
|
|
50
51
|
fs.mkdirSync(snapshotsDir(root), { recursive: true });
|
|
52
|
+
ensurePmRuntimeIgnored(root);
|
|
53
|
+
}
|
|
54
|
+
/** 本机协调/缓存状态必须留在 .pm 内共享,但绝不能进入项目 Git。 */
|
|
55
|
+
export function ensurePmRuntimeIgnored(root) {
|
|
56
|
+
const directory = pmPath(root);
|
|
57
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
58
|
+
fs.mkdirSync(path.join(directory, ".runtime", "idempotency"), { recursive: true });
|
|
59
|
+
const file = path.join(directory, ".gitignore");
|
|
60
|
+
const required = [".lock", ".runtime/", "benchmarks/", "index.db*"];
|
|
61
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
62
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
63
|
+
const lines = current.split(/\r?\n/).map((line) => line.trim());
|
|
64
|
+
const missing = required.filter((entry) => !lines.includes(entry));
|
|
65
|
+
if (missing.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
const separator = current.length === 0 || current.endsWith("\n") ? "" : "\n";
|
|
68
|
+
const content = `${current}${separator}${missing.join("\n")}\n`;
|
|
69
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
70
|
+
fs.writeFileSync(temporary, content, "utf8");
|
|
71
|
+
try {
|
|
72
|
+
fs.renameSync(temporary, file);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(temporary, { force: true });
|
|
77
|
+
}
|
|
78
|
+
catch { /* best effort */ }
|
|
79
|
+
const converged = fs.existsSync(file) && required.every((entry) => fs.readFileSync(file, "utf8").split(/\r?\n/).map((line) => line.trim()).includes(entry));
|
|
80
|
+
if (converged)
|
|
81
|
+
return;
|
|
82
|
+
if (attempt === 2)
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
51
86
|
}
|
package/dist/search.js
CHANGED
|
@@ -74,7 +74,7 @@ let diskReadBytes = 0;
|
|
|
74
74
|
let cacheHits = 0;
|
|
75
75
|
let contentReadObserver = null;
|
|
76
76
|
/** 读取文本内容:mtime+size 未变走进程缓存(server 常驻,二次搜索/扫描免重读)。返回 null=不可读/二进制。 */
|
|
77
|
-
export function readText(root, rel) {
|
|
77
|
+
export function readText(root, rel, force = false) {
|
|
78
78
|
const abs = path.join(root, rel);
|
|
79
79
|
// 缓存必须按“项目根 + 相对路径”隔离;仅用 rel 会让两个项目在
|
|
80
80
|
// mtime/size 恰好相同时串用彼此内容,安全与许可证扫描会随之失真。
|
|
@@ -87,7 +87,7 @@ export function readText(root, rel) {
|
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
89
|
const hit = contentCache.get(cacheKey);
|
|
90
|
-
if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) {
|
|
90
|
+
if (!force && hit && hit.mtime === st.mtimeMs && hit.size === st.size) {
|
|
91
91
|
// LRU touch
|
|
92
92
|
contentCache.delete(cacheKey);
|
|
93
93
|
contentCache.set(cacheKey, hit);
|
package/dist/security.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { loadExtraRules, loadProject, loadSecurity, saveSecurity } from "./store.js";
|
|
3
3
|
import { latestSnapshot } from "./audit.js";
|
|
4
4
|
import { readDirectDeps } from "./scan.js";
|
|
5
|
-
import { ensureFresh, getIndex, iterateFileRows } from "./index-store.js";
|
|
5
|
+
import { ensureFresh, getIndex, iterateFileRows, walkRefresh } from "./index-store.js";
|
|
6
6
|
import { readText } from "./search.js";
|
|
7
7
|
import { now } from "./types.js";
|
|
8
8
|
const SECRET_PATTERNS = [
|
|
@@ -69,7 +69,7 @@ function findSecretRule(value) {
|
|
|
69
69
|
function fingerprint(ruleId, rel, matched) {
|
|
70
70
|
return createHash("sha1").update(ruleId + "|" + rel + "|" + matched).digest("hex").slice(0, 16);
|
|
71
71
|
}
|
|
72
|
-
function scanDetections(root) {
|
|
72
|
+
function scanDetections(root, forceContent = false, indexPrepared = false) {
|
|
73
73
|
const out = [];
|
|
74
74
|
const rules = [...SECRET_PATTERNS, ...DANGEROUS_PATTERNS];
|
|
75
75
|
// 用户自定义规则
|
|
@@ -87,7 +87,8 @@ function scanDetections(root) {
|
|
|
87
87
|
/* 非法自定义规则跳过 */
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
-
|
|
90
|
+
if (!indexPrepared)
|
|
91
|
+
ensureFresh(root);
|
|
91
92
|
const db = getIndex(root);
|
|
92
93
|
// .env 类文件存在性检查(是否被 git 提交需人工确认)
|
|
93
94
|
for (const row of iterateFileRows(db, { excludeOversize: false })) {
|
|
@@ -109,7 +110,7 @@ function scanDetections(root) {
|
|
|
109
110
|
continue;
|
|
110
111
|
if (rel.startsWith(".pm/"))
|
|
111
112
|
continue;
|
|
112
|
-
const content = readText(root, rel);
|
|
113
|
+
const content = readText(root, rel, forceContent);
|
|
113
114
|
if (content === null)
|
|
114
115
|
continue;
|
|
115
116
|
const lines = content.split("\n");
|
|
@@ -134,11 +135,19 @@ function scanDetections(root) {
|
|
|
134
135
|
}
|
|
135
136
|
return out;
|
|
136
137
|
}
|
|
138
|
+
/** 昂贵的全仓扫描阶段;调用方可在账本锁外完成,再把脱敏发现提交到账本。 */
|
|
139
|
+
export function prepareSecurityAudit(root, options = {}) {
|
|
140
|
+
const forceIndex = options.forceIndex ?? true;
|
|
141
|
+
const forceContent = options.forceContent ?? true;
|
|
142
|
+
if (forceIndex)
|
|
143
|
+
walkRefresh(root, { forceContent: true });
|
|
144
|
+
return { detections: scanDetections(root, forceContent, forceIndex || options.indexPrepared === true) };
|
|
145
|
+
}
|
|
137
146
|
/** 扫描并更新台账(幂等:重复扫描不产生重复条目) */
|
|
138
|
-
export function auditSecurity(root) {
|
|
147
|
+
export function auditSecurity(root, prepared) {
|
|
139
148
|
const project = loadProject(root);
|
|
140
149
|
const stamp = now();
|
|
141
|
-
const detections = scanDetections(root);
|
|
150
|
+
const detections = prepared?.detections ?? scanDetections(root);
|
|
142
151
|
const security = loadSecurity(root);
|
|
143
152
|
const existing = new Map(security.findings.map((f) => [f.fingerprint, f]));
|
|
144
153
|
const acceptedRefound = [];
|
package/dist/setup.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.
|
|
5
|
+
export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.4";
|
|
6
6
|
export const SERVER_NAME = "pm-mcp";
|
|
7
7
|
const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode"];
|
|
8
8
|
export function parseSetupArgs(argv) {
|