@luckychen1993/pm-mcp 0.1.2 → 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 +11 -5
- package/dist/acceptance-tools.js +6 -6
- package/dist/audit-tools.js +14 -13
- package/dist/audit.js +10 -5
- package/dist/governance-tools.js +11 -11
- package/dist/idempotency.js +427 -0
- package/dist/index-store.js +163 -94
- package/dist/index.js +28 -17
- package/dist/knowledge-tools.js +5 -5
- package/dist/license.js +7 -4
- package/dist/paths.js +35 -0
- package/dist/project-tools.js +4 -4
- package/dist/search.js +2 -2
- package/dist/security.js +15 -6
- package/dist/setup.js +1 -1
- package/dist/store.js +185 -35
- package/dist/task-tools.js +2 -2
- package/dist/tool-base.js +33 -10
- package/dist/tools.js +1 -1
- 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,13 @@ 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" }, {
|
|
27
|
+
instructions: [
|
|
28
|
+
"多 Agent 规则:相同业务的所有写工具调用必须携带相同 idempotency_key(建议 task-id:operation);同键同参数只执行一次,同键不同参数会被拒绝。",
|
|
29
|
+
"完全相同的并行读请求会由服务端合并并复用结果;不要让多个分支重复请求同一工具和参数。",
|
|
30
|
+
"看到“幂等复用”时直接采用首次结果;看到“幂等占位/正在执行”时等待负责该业务的 Agent,不要换键重试。",
|
|
31
|
+
].join(" "),
|
|
32
|
+
});
|
|
26
33
|
registerAllTools(server, root);
|
|
27
34
|
/* -------------------------------- Resources ------------------------------- */
|
|
28
35
|
function safeText(fn) {
|
|
@@ -33,30 +40,33 @@ function safeText(fn) {
|
|
|
33
40
|
return `(不可用: ${e.message})`;
|
|
34
41
|
}
|
|
35
42
|
}
|
|
36
|
-
|
|
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) => ({
|
|
37
47
|
contents: [
|
|
38
48
|
{
|
|
39
49
|
uri: uri.href,
|
|
40
50
|
mimeType: "text/markdown",
|
|
41
|
-
text:
|
|
51
|
+
text: await resourceText("dashboard", () => fs.readFileSync(dashboardFile(root), "utf8")),
|
|
42
52
|
},
|
|
43
53
|
],
|
|
44
54
|
}));
|
|
45
|
-
server.registerResource("roadmap", "pm://roadmap", { description: "路线图(里程碑进度 + 活跃里程碑任务明细)" }, (uri) => ({
|
|
55
|
+
server.registerResource("roadmap", "pm://roadmap", { description: "路线图(里程碑进度 + 活跃里程碑任务明细)" }, async (uri) => ({
|
|
46
56
|
contents: [
|
|
47
57
|
{
|
|
48
58
|
uri: uri.href,
|
|
49
59
|
mimeType: "text/markdown",
|
|
50
|
-
text:
|
|
60
|
+
text: await resourceText("roadmap", () => renderRoadmap(loadRoadmap(root).milestones, loadTasks(root).tasks, 2).join("\n")),
|
|
51
61
|
},
|
|
52
62
|
],
|
|
53
63
|
}));
|
|
54
|
-
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) => ({
|
|
55
65
|
contents: [
|
|
56
66
|
{
|
|
57
67
|
uri: uri.href,
|
|
58
68
|
mimeType: "application/json",
|
|
59
|
-
text:
|
|
69
|
+
text: await resourceText("tasks", () => {
|
|
60
70
|
const tasks = loadTasks(root).tasks;
|
|
61
71
|
// 资源也要守 token 预算:全量 dump 大账本会吃爆上下文(截断后仍是合法 JSON)
|
|
62
72
|
if (tasks.length <= 100)
|
|
@@ -71,30 +81,30 @@ server.registerResource("tasks", "pm://tasks", { description: "任务清单原
|
|
|
71
81
|
},
|
|
72
82
|
],
|
|
73
83
|
}));
|
|
74
|
-
server.registerResource("changelog", "pm://changelog", { description: "变更日志(由 sessions.json 生成的人类可读版本)" }, (uri) => ({
|
|
84
|
+
server.registerResource("changelog", "pm://changelog", { description: "变更日志(由 sessions.json 生成的人类可读版本)" }, async (uri) => ({
|
|
75
85
|
contents: [
|
|
76
86
|
{
|
|
77
87
|
uri: uri.href,
|
|
78
88
|
mimeType: "text/markdown",
|
|
79
|
-
text:
|
|
89
|
+
text: await resourceText("changelog", () => buildChangelog(root)),
|
|
80
90
|
},
|
|
81
91
|
],
|
|
82
92
|
}));
|
|
83
|
-
server.registerResource("architecture", "pm://architecture", { description: "跨文件/模块/语言治理审计:语义覆盖、依赖边界、接口、循环与质量矩阵" }, (uri) => ({
|
|
84
|
-
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) }],
|
|
85
95
|
}));
|
|
86
|
-
server.registerResource("portfolio", "pm://portfolio", { description: "当前项目的组合视图与跨仓依赖/版本风险" }, (uri) => ({
|
|
96
|
+
server.registerResource("portfolio", "pm://portfolio", { description: "当前项目的组合视图与跨仓依赖/版本风险" }, async (uri) => ({
|
|
87
97
|
contents: [{
|
|
88
98
|
uri: uri.href,
|
|
89
99
|
mimeType: "application/json",
|
|
90
|
-
text:
|
|
100
|
+
text: await resourceText("portfolio", () => JSON.stringify(buildPortfolioReport({ projects: [loadPortfolioProject(root)], projectsRequested: 1 }), null, 2)),
|
|
91
101
|
}],
|
|
92
102
|
}));
|
|
93
|
-
server.registerResource("acceptance", "pm://acceptance", { description: "标准化产品验收概览:版本化预批准基线与最近一次机器判定报告" }, (uri) => ({
|
|
103
|
+
server.registerResource("acceptance", "pm://acceptance", { description: "标准化产品验收概览:版本化预批准基线与最近一次机器判定报告" }, async (uri) => ({
|
|
94
104
|
contents: [{
|
|
95
105
|
uri: uri.href,
|
|
96
106
|
mimeType: "application/json",
|
|
97
|
-
text:
|
|
107
|
+
text: await resourceText("acceptance", () => {
|
|
98
108
|
const reportDir = pmPath(root, "acceptance", "reports");
|
|
99
109
|
let latestReport = null;
|
|
100
110
|
if (fs.existsSync(reportDir)) {
|
|
@@ -203,8 +213,9 @@ await server.connect(new StdioServerTransport());
|
|
|
203
213
|
// 超大项目核心:server 常驻期间用递归 watcher 持续保鲜 SQLite 索引,
|
|
204
214
|
// audit/快照稳态下免全量走查(失败自动降级为纯走查模式)
|
|
205
215
|
if (isInitialized(root)) {
|
|
216
|
+
ensurePmRuntimeIgnored(root);
|
|
206
217
|
const watcher = startWatcher(root);
|
|
207
|
-
console.error(`[pm-mcp] watcher: ${watcher ? "active" : "unavailable(降级为按需全量走查)"}`);
|
|
218
|
+
console.error(`[pm-mcp] watcher coordinator: ${watcher ? "active(同项目单 leader)" : "unavailable(降级为按需全量走查)"}`);
|
|
208
219
|
}
|
|
209
220
|
// stdio 是协议通道,日志只能走 stderr
|
|
210
221
|
console.error(`[pm-mcp] ready. project root: ${root}`);
|
package/dist/knowledge-tools.js
CHANGED
|
@@ -6,7 +6,7 @@ import { refreshDerived } from "./dashboard.js";
|
|
|
6
6
|
import { decisionsDir, requireInitialized } from "./paths.js";
|
|
7
7
|
import { formatCodeSearch, searchCode, searchKnowledge } from "./search.js";
|
|
8
8
|
import { loadDebugLog, loadFeatures, loadFileNotes, loadProject, loadSessions, nextId, saveDebugLog, saveFeatures, saveFileNotes, saveSessions, } from "./store.js";
|
|
9
|
-
import { budgetLines,
|
|
9
|
+
import { budgetLines, toolR, toolW } from "./tool-base.js";
|
|
10
10
|
import { now } from "./types.js";
|
|
11
11
|
export function registerFeatureTools(server, root) {
|
|
12
12
|
toolW(server, root, "register_feature", "登记功能(回答「这个项目有哪些功能」)。entry_files=入口文件(漂移对账锚点),test_files=测试文件(测试背书)。", {
|
|
@@ -41,7 +41,7 @@ export function registerFeatureTools(server, root) {
|
|
|
41
41
|
: "";
|
|
42
42
|
return `✅ 功能 ${id}「${args.name}」已登记${args.module ? `(${args.module})` : ""}。audit_structure 会核对其入口文件是否真实存在(防幻觉对账)。${hint}`;
|
|
43
43
|
});
|
|
44
|
-
|
|
44
|
+
toolR(server, root, "list_features", "列出功能清单(可按模块/状态过滤)。", { module: z.string().optional(), status: z.enum(["planned", "implemented", "deprecated"]).optional() }, (args) => {
|
|
45
45
|
requireInitialized(root);
|
|
46
46
|
let list = loadFeatures(root).features;
|
|
47
47
|
if (args.module)
|
|
@@ -95,7 +95,7 @@ export function registerDecisionTools(server, root) {
|
|
|
95
95
|
refreshDerived(root);
|
|
96
96
|
return `✅ 决策已记录: .pm/decisions/${file}(不可变编号,git 友好)。search_knowledge 可检索。`;
|
|
97
97
|
});
|
|
98
|
-
|
|
98
|
+
toolR(server, root, "list_decisions", "列出全部架构决策(ADR)。", {}, () => {
|
|
99
99
|
requireInitialized(root);
|
|
100
100
|
const dir = decisionsDir(root);
|
|
101
101
|
const names = fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => n.endsWith(".md")).sort() : [];
|
|
@@ -164,7 +164,7 @@ export function registerSessionTools(server, root) {
|
|
|
164
164
|
});
|
|
165
165
|
}
|
|
166
166
|
export function registerSearchTools(server, root) {
|
|
167
|
-
|
|
167
|
+
toolR(server, root, "search_code", "在真实代码里检索(防幻觉事中防线):谈某个功能/文件之前先搜实码。返回 file:line 片段而非整文件(token 经济)。默认按字面量匹配;regex=true 时按 JavaScript 正则(仅在你确需时开启)。", {
|
|
168
168
|
query: z.string().min(1).describe("关键词(默认字面量匹配)"),
|
|
169
169
|
glob: z.string().optional().describe("限定文件范围,如 src/**/*.ts"),
|
|
170
170
|
max_results: z.number().int().min(1).max(100).optional().describe("默认 30"),
|
|
@@ -174,7 +174,7 @@ export function registerSearchTools(server, root) {
|
|
|
174
174
|
const res = searchCode(root, args.query, args.glob, args.max_results ?? 30, args.regex ?? false);
|
|
175
175
|
return formatCodeSearch(res, args.query);
|
|
176
176
|
});
|
|
177
|
-
|
|
177
|
+
toolR(server, root, "search_knowledge", "检索六类项目知识源(ADR/任务/功能/会话/调试记录/文件索引)——回答「之前为什么这么做」「上次这个错怎么解的」,用历史结论代替重新推理。代码内容请用 search_code。", { query: z.string().min(1) }, (args) => {
|
|
178
178
|
requireInitialized(root);
|
|
179
179
|
return searchKnowledge(root, args.query);
|
|
180
180
|
});
|
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/project-tools.js
CHANGED
|
@@ -7,7 +7,7 @@ import { foldLines } from "./budget.js";
|
|
|
7
7
|
import { loadGovernance } from "./governance-model.js";
|
|
8
8
|
import { loadProject, loadRoadmap, loadSecurity, loadSessions, loadTasks, saveProject, saveRoadmap, } from "./store.js";
|
|
9
9
|
import { now } from "./types.js";
|
|
10
|
-
import { budgetLines,
|
|
10
|
+
import { budgetLines, toolI, toolR, toolW } from "./tool-base.js";
|
|
11
11
|
function taskLine(t, withNext = false) {
|
|
12
12
|
const parts = [`[${t.status}]`, t.id, t.title, `(${t.type}${t.milestone ? `,${t.milestone}` : ""})`];
|
|
13
13
|
if (withNext && t.checkpoint) {
|
|
@@ -34,7 +34,7 @@ export function registerProjectTools(server, root) {
|
|
|
34
34
|
"建议顺序: upsert_module 声明模块/owner/语言 → add_milestone 建里程碑 → add_task 拆任务 → 开工前 get_status / 收工后 log_session。",
|
|
35
35
|
].join("\n");
|
|
36
36
|
});
|
|
37
|
-
|
|
37
|
+
toolR(server, root, "get_status", "一站式「我在哪」:项目阶段 + 里程碑进度 + 进行中任务(含断点下一步)+ 健康摘要 + 最近变更。会话开工第一件事。since 可只看某日期以来的任务/会话变化。", { since: z.string().optional().describe("ISO 日期,如 2026-09-01:只看该日期以来的任务/会话变化") }, (args) => {
|
|
38
38
|
requireInitialized(root);
|
|
39
39
|
const project = loadProject(root);
|
|
40
40
|
const { milestones } = loadRoadmap(root);
|
|
@@ -136,7 +136,7 @@ export function registerProjectTools(server, root) {
|
|
|
136
136
|
refreshDerived(root);
|
|
137
137
|
return `✅ 项目信息已更新(阶段: ${project.phase || "—"},模块: ${project.modules.join(", ") || "—"})。仪表盘已刷新。`;
|
|
138
138
|
});
|
|
139
|
-
|
|
139
|
+
toolI(server, root, "regenerate_dashboard", "手动重新生成 PROJECT.md 仪表盘与 changelog.md(正常情况下每次写操作已自动刷新)。", {}, () => {
|
|
140
140
|
requireInitialized(root);
|
|
141
141
|
refreshDerived(root);
|
|
142
142
|
return "✅ PROJECT.md 与 changelog.md 已重新生成。";
|
|
@@ -196,7 +196,7 @@ export function registerRoadmapTools(server, root) {
|
|
|
196
196
|
const s = milestoneStats(tasks, m.id);
|
|
197
197
|
return [`✅ ${m.id} 已更新(${m.status})。进度: ${s.done}/${s.total}(${s.pct}%)。`, ...warnings].join("\n");
|
|
198
198
|
});
|
|
199
|
-
|
|
199
|
+
toolR(server, root, "get_roadmap", "查看路线图。depth=1 每个里程碑一行摘要(功能再多也不乱);depth=2 额外展开活跃里程碑的任务明细与断点。", { depth: z.number().int().min(1).max(2).optional().describe("默认 1") }, (args) => {
|
|
200
200
|
requireInitialized(root);
|
|
201
201
|
const { milestones } = loadRoadmap(root);
|
|
202
202
|
const { tasks } = loadTasks(root);
|
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);
|