@luckychen1993/pm-mcp 0.1.1
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/LICENSE +21 -0
- package/README.md +304 -0
- package/dist/acceptance-evaluator.js +330 -0
- package/dist/acceptance-model.js +440 -0
- package/dist/acceptance-report.js +335 -0
- package/dist/acceptance-tools.js +318 -0
- package/dist/audit-tools.js +61 -0
- package/dist/audit.js +364 -0
- package/dist/budget.js +71 -0
- package/dist/dashboard.js +241 -0
- package/dist/governance-audit.js +126 -0
- package/dist/governance-model.js +306 -0
- package/dist/governance-tools.js +240 -0
- package/dist/health.js +58 -0
- package/dist/index-db.js +97 -0
- package/dist/index-store.js +381 -0
- package/dist/index.js +210 -0
- package/dist/init.js +40 -0
- package/dist/knowledge-tools.js +204 -0
- package/dist/language-adapters.js +450 -0
- package/dist/language-dependencies.js +351 -0
- package/dist/license.js +261 -0
- package/dist/paths.js +51 -0
- package/dist/polyglot-ast.js +329 -0
- package/dist/portfolio.js +349 -0
- package/dist/project-fingerprint.js +83 -0
- package/dist/project-tools.js +217 -0
- package/dist/quality-store.js +77 -0
- package/dist/registry.js +38 -0
- package/dist/roadmap.js +96 -0
- package/dist/scan.js +271 -0
- package/dist/search.js +312 -0
- package/dist/security.js +269 -0
- package/dist/semantic-evidence-store.js +133 -0
- package/dist/semantic-evidence.js +100 -0
- package/dist/semantic-graph-algorithms.js +86 -0
- package/dist/semantic-graph.js +390 -0
- package/dist/semantic-parsers.js +311 -0
- package/dist/store.js +185 -0
- package/dist/task-tools.js +198 -0
- package/dist/tool-base.js +32 -0
- package/dist/tools.js +21 -0
- package/dist/types.js +208 -0
- package/dist/typescript-semantic.js +282 -0
- package/package.json +70 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { isInitialized } from "./paths.js";
|
|
5
|
+
import { normSep } from "./budget.js";
|
|
6
|
+
import { getIndex, getMeta, setMeta } from "./index-db.js";
|
|
7
|
+
export { aggregates, closeIndex, getIndex, getMeta, iterateFileRows, setMeta } from "./index-db.js";
|
|
8
|
+
import { BINARY_EXTS, countLoc, isLockfile, isTestFile, extOf, countSkipLines, needsSkipCount, looksTrivialTest, DEFAULT_IGNORE_DIRS, walkStatEntries, } from "./scan.js";
|
|
9
|
+
/**
|
|
10
|
+
* SQLite 扫描索引(超大项目核心):
|
|
11
|
+
* - .pm/index.db 取代 index.json:增量 upsert 而非全量重写(10M 行 JSON 会膨胀到 GB 级)
|
|
12
|
+
* - 索引是可重建的缓存(.gitignore 它),账本仍走 git 友好的 JSON——两者是两回事
|
|
13
|
+
* - 走查 = temp 表集合 diff:未变更文件不重读内容(与旧 JSON 索引同语义)
|
|
14
|
+
* - 聚合全部走 SQL:避免把大规模索引整体物化进 JS 内存
|
|
15
|
+
* - watcher 活跃时索引持续保鲜,audit/快照可免走查(见 ensureFresh)
|
|
16
|
+
*/
|
|
17
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
/** 读单个文件内容并计算索引字段(watcher 与走查共用同一条语义) */
|
|
19
|
+
export function computeEntry(root, rel, stat) {
|
|
20
|
+
const ext = extOf(rel);
|
|
21
|
+
const lock = isLockfile(rel);
|
|
22
|
+
const isTest = isTestFile(rel);
|
|
23
|
+
if (BINARY_EXTS.has(ext) || lock) {
|
|
24
|
+
return { loc: 0, skip: 0, trivial: false, oversize: false, contentOk: false };
|
|
25
|
+
}
|
|
26
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
27
|
+
return { loc: 0, skip: 0, trivial: false, oversize: true, contentOk: false };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const content = fs.readFileSync(path.join(root, ...rel.split("/")), "utf8");
|
|
31
|
+
if (content.includes("\0"))
|
|
32
|
+
return { loc: 0, skip: 0, trivial: false, oversize: false, contentOk: false };
|
|
33
|
+
return {
|
|
34
|
+
loc: countLoc(content),
|
|
35
|
+
skip: needsSkipCount(rel, isTest) ? countSkipLines(content) : 0,
|
|
36
|
+
trivial: isTest && looksTrivialTest(content),
|
|
37
|
+
oversize: false,
|
|
38
|
+
contentOk: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { loc: 0, skip: 0, trivial: false, oversize: false, contentOk: false };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function topOf(rel) {
|
|
46
|
+
const parts = rel.split("/");
|
|
47
|
+
if (parts.length <= 1)
|
|
48
|
+
return ".";
|
|
49
|
+
return parts.slice(0, Math.min(2, parts.length - 1)).join("/");
|
|
50
|
+
}
|
|
51
|
+
export function upsertFile(root, db, rel, stat, entry) {
|
|
52
|
+
const parts = rel.split("/");
|
|
53
|
+
db.prepare(`INSERT INTO files(rel,top,root1,dir,ext,is_test,is_lock,mtime,size,loc,skip,trivial,oversize,content_ok)
|
|
54
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
55
|
+
ON CONFLICT(rel) DO UPDATE SET top=excluded.top,root1=excluded.root1,dir=excluded.dir,ext=excluded.ext,
|
|
56
|
+
is_test=excluded.is_test,is_lock=excluded.is_lock,mtime=excluded.mtime,size=excluded.size,
|
|
57
|
+
loc=excluded.loc,skip=excluded.skip,trivial=excluded.trivial,oversize=excluded.oversize,
|
|
58
|
+
content_ok=excluded.content_ok`).run(rel, topOf(rel), parts.length > 1 ? parts[0] : ".", parts.slice(0, -1).join("/") || ".", extOf(rel), isTestFile(rel) ? 1 : 0, isLockfile(rel) ? 1 : 0, stat.mtimeMs, stat.size, entry.loc, entry.skip, entry.trivial ? 1 : 0, entry.oversize ? 1 : 0, entry.contentOk ? 1 : 0);
|
|
59
|
+
}
|
|
60
|
+
export function deleteFile(db, rel) {
|
|
61
|
+
db.prepare("DELETE FROM files WHERE rel=?").run(rel);
|
|
62
|
+
}
|
|
63
|
+
export function deleteSubtree(db, prefix) {
|
|
64
|
+
db.prepare("DELETE FROM files WHERE rel LIKE ?").run(prefix.replace(/[%_]/g, (c) => "\\" + c) + "/%");
|
|
65
|
+
}
|
|
66
|
+
export function walkRefresh(root, opts = {}) {
|
|
67
|
+
const db = getIndex(root);
|
|
68
|
+
db.exec("CREATE TEMP TABLE IF NOT EXISTS walked(rel TEXT PRIMARY KEY, mtime REAL, size INTEGER)");
|
|
69
|
+
db.exec("DELETE FROM walked");
|
|
70
|
+
const ins = db.prepare("INSERT OR REPLACE INTO walked VALUES(?,?,?)");
|
|
71
|
+
let total = 0;
|
|
72
|
+
let batch = [];
|
|
73
|
+
const flush = () => {
|
|
74
|
+
if (batch.length === 0)
|
|
75
|
+
return;
|
|
76
|
+
db.exec("BEGIN");
|
|
77
|
+
for (const b of batch)
|
|
78
|
+
ins.run(...b);
|
|
79
|
+
db.exec("COMMIT");
|
|
80
|
+
batch = [];
|
|
81
|
+
};
|
|
82
|
+
let skippedDeep = 0;
|
|
83
|
+
for (const e of walkStatEntries(root, opts, () => (skippedDeep += 1))) {
|
|
84
|
+
total += 1;
|
|
85
|
+
batch.push([e.rel, e.mtime, e.size]);
|
|
86
|
+
if (batch.length >= 2000)
|
|
87
|
+
flush();
|
|
88
|
+
}
|
|
89
|
+
flush();
|
|
90
|
+
const changedSql = opts.forceContent
|
|
91
|
+
? `SELECT w.rel AS rel, w.mtime AS mtime, w.size AS size FROM walked w
|
|
92
|
+
WHERE w.rel > ? ORDER BY w.rel LIMIT ?`
|
|
93
|
+
: `SELECT w.rel AS rel, w.mtime AS mtime, w.size AS size FROM walked w
|
|
94
|
+
LEFT JOIN files f ON f.rel = w.rel
|
|
95
|
+
WHERE w.rel > ? AND (f.rel IS NULL OR f.mtime <> w.mtime OR f.size <> w.size)
|
|
96
|
+
ORDER BY w.rel LIMIT ?`;
|
|
97
|
+
const changedStmt = db.prepare(changedSql);
|
|
98
|
+
let processed = 0;
|
|
99
|
+
const upsertStmt = db.prepare(`INSERT INTO files(rel,top,root1,dir,ext,is_test,is_lock,mtime,size,loc,skip,trivial,oversize,content_ok)
|
|
100
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
101
|
+
ON CONFLICT(rel) DO UPDATE SET top=excluded.top,root1=excluded.root1,dir=excluded.dir,ext=excluded.ext,
|
|
102
|
+
is_test=excluded.is_test,is_lock=excluded.is_lock,mtime=excluded.mtime,size=excluded.size,
|
|
103
|
+
loc=excluded.loc,skip=excluded.skip,trivial=excluded.trivial,oversize=excluded.oversize,
|
|
104
|
+
content_ok=excluded.content_ok`);
|
|
105
|
+
let upBatch = [];
|
|
106
|
+
const flushUp = () => {
|
|
107
|
+
if (upBatch.length === 0)
|
|
108
|
+
return;
|
|
109
|
+
db.exec("BEGIN");
|
|
110
|
+
for (const r of upBatch) {
|
|
111
|
+
upsertStmt.run(r.rel, r.top, r.root1, r.dir, r.ext, r.isTest, r.isLock, r.mtime, r.size, r.loc, r.skip, r.trivial, r.oversize, r.contentOk);
|
|
112
|
+
}
|
|
113
|
+
db.exec("COMMIT");
|
|
114
|
+
upBatch = [];
|
|
115
|
+
};
|
|
116
|
+
// 游标分页而不是 .all() 物化全部变更行;20GiB/百万文件首次走查也只保留一批。
|
|
117
|
+
let changedCursor = "";
|
|
118
|
+
while (true) {
|
|
119
|
+
const changedRows = changedStmt.all(changedCursor, 500);
|
|
120
|
+
if (changedRows.length === 0)
|
|
121
|
+
break;
|
|
122
|
+
for (const row of changedRows) {
|
|
123
|
+
const rel = row.rel;
|
|
124
|
+
const statLike = { mtimeMs: row.mtime, size: row.size };
|
|
125
|
+
const entry = computeEntry(root, rel, statLike);
|
|
126
|
+
const parts = rel.split("/");
|
|
127
|
+
upBatch.push({
|
|
128
|
+
rel,
|
|
129
|
+
top: topOf(rel),
|
|
130
|
+
root1: parts.length > 1 ? parts[0] : ".",
|
|
131
|
+
dir: parts.slice(0, -1).join("/") || ".",
|
|
132
|
+
ext: extOf(rel),
|
|
133
|
+
isTest: isTestFile(rel) ? 1 : 0,
|
|
134
|
+
isLock: isLockfile(rel) ? 1 : 0,
|
|
135
|
+
mtime: row.mtime,
|
|
136
|
+
size: row.size,
|
|
137
|
+
loc: entry.loc,
|
|
138
|
+
skip: entry.skip,
|
|
139
|
+
trivial: entry.trivial ? 1 : 0,
|
|
140
|
+
oversize: entry.oversize ? 1 : 0,
|
|
141
|
+
contentOk: entry.contentOk ? 1 : 0,
|
|
142
|
+
});
|
|
143
|
+
processed += 1;
|
|
144
|
+
if (upBatch.length >= 500)
|
|
145
|
+
flushUp();
|
|
146
|
+
}
|
|
147
|
+
changedCursor = changedRows[changedRows.length - 1].rel;
|
|
148
|
+
}
|
|
149
|
+
flushUp();
|
|
150
|
+
const del = db.prepare(`DELETE FROM files WHERE rel NOT IN (SELECT rel FROM walked)`).run();
|
|
151
|
+
setMeta(db, "lastWalk", new Date().toISOString());
|
|
152
|
+
setMeta(db, "skippedDeep", String(skippedDeep));
|
|
153
|
+
setMeta(db, "rootPath", path.resolve(root));
|
|
154
|
+
// 走查刚完成的这一刻索引就是最新的:watcher 模式下立即续心跳,
|
|
155
|
+
// 否则长走查期间(库忙)心跳可能一直写不进,走查一结束就被判"过期"再走一遍
|
|
156
|
+
if (getMeta(db, "mode") === "watcher") {
|
|
157
|
+
setMeta(db, "lastBeat", new Date().toISOString());
|
|
158
|
+
// watcher 心跳只能证明“现在有人监听”,不能证明它覆盖了进程停机期间的改动。
|
|
159
|
+
// 将精确走查绑定到本次 watcher 会话;下次进程启动会换代,旧索引因而必须先对账。
|
|
160
|
+
const watcherSession = getMeta(db, "watcherSession");
|
|
161
|
+
if (watcherSession !== null)
|
|
162
|
+
setMeta(db, "lastWalkSession", watcherSession);
|
|
163
|
+
}
|
|
164
|
+
return { totalFiles: total, changed: processed, deleted: Number(del.changes ?? 0), hits: total - processed, skippedDeep };
|
|
165
|
+
}
|
|
166
|
+
const BEAT_STALE_MS = 90_000;
|
|
167
|
+
export function freshness(root) {
|
|
168
|
+
const db = getIndex(root);
|
|
169
|
+
const files = db.prepare(`SELECT COUNT(*) c FROM files`).get().c;
|
|
170
|
+
const lastWalk = getMeta(db, "lastWalk");
|
|
171
|
+
const lastBeat = getMeta(db, "lastBeat");
|
|
172
|
+
const mode = (getMeta(db, "mode") ?? "walk");
|
|
173
|
+
const pending = Number(getMeta(db, "pending") ?? 0);
|
|
174
|
+
const beatAge = lastBeat ? Date.now() - Date.parse(lastBeat) : Number.POSITIVE_INFINITY;
|
|
175
|
+
// rootPath 守卫:索引库随项目目录被拷贝/移动时,旧心跳与行集都属于原路径——不可信任
|
|
176
|
+
const owned = getMeta(db, "rootPath") === path.resolve(root);
|
|
177
|
+
const lastEvent = getMeta(db, "lastEvent");
|
|
178
|
+
const eventAge = lastEvent ? Date.now() - Date.parse(lastEvent) : Number.POSITIVE_INFINITY;
|
|
179
|
+
const watcherSession = getMeta(db, "watcherSession");
|
|
180
|
+
const sessionReconciled = watcherSession !== null && getMeta(db, "lastWalkSession") === watcherSession;
|
|
181
|
+
// 卡死计数自愈:防抖 150ms,若计数>0 但 30s 无任何事件推进,说明计数器失真(如持锁期抛错),不再信任
|
|
182
|
+
const pendingClean = pending === 0 || eventAge > 30_000;
|
|
183
|
+
const fresh = files > 0 &&
|
|
184
|
+
lastWalk !== null &&
|
|
185
|
+
owned &&
|
|
186
|
+
mode === "watcher" &&
|
|
187
|
+
sessionReconciled &&
|
|
188
|
+
pendingClean &&
|
|
189
|
+
beatAge < BEAT_STALE_MS;
|
|
190
|
+
return { mode, fresh, files, lastWalk, lastBeat, pendingEvents: pending };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 确保索引可用:watcher 活跃且心跳新鲜 → 直接用(稳态免走查);
|
|
194
|
+
* 否则精确全量走查(冷启动/巡检进程/server 未运行时)。
|
|
195
|
+
*/
|
|
196
|
+
export function ensureFresh(root) {
|
|
197
|
+
let f = freshness(root);
|
|
198
|
+
if (!f.fresh && f.mode === "watcher" && f.pendingEvents > 0) {
|
|
199
|
+
// 变更风暴刚过:事件还在防抖队列里。本进程有 watcher 时就地排空(毫秒级),
|
|
200
|
+
// 免得为几十个文件的变更回退一次全量走查(分钟级)
|
|
201
|
+
if (drainWatcher(root))
|
|
202
|
+
f = freshness(root);
|
|
203
|
+
}
|
|
204
|
+
if (f.fresh)
|
|
205
|
+
return { used: "watcher", freshness: f };
|
|
206
|
+
walkRefresh(root);
|
|
207
|
+
return { used: "walk", freshness: freshness(root) };
|
|
208
|
+
}
|
|
209
|
+
const activeWatchers = new Map();
|
|
210
|
+
/**
|
|
211
|
+
* 就地排空本进程 watcher 的事件队列:把已排队的防抖定时器立即执行。
|
|
212
|
+
* 场景:变更风暴后紧接着调 audit——事件本要 150ms 后才消化、pending>0 导致回退全量走查;
|
|
213
|
+
* 排空后索引立即反映全部变更,审计直接走 SQL。返回是否发生排空。
|
|
214
|
+
*/
|
|
215
|
+
export function drainWatcher(root) {
|
|
216
|
+
const w = activeWatchers.get(path.resolve(root));
|
|
217
|
+
if (!w || w.closed())
|
|
218
|
+
return false;
|
|
219
|
+
let drained = 0;
|
|
220
|
+
for (const [, entry] of [...w.timers]) {
|
|
221
|
+
clearTimeout(entry.timer);
|
|
222
|
+
entry.run();
|
|
223
|
+
drained += 1;
|
|
224
|
+
}
|
|
225
|
+
return drained > 0;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 递归 watcher(Windows 原生 ReadDirectoryChangesW,单调用监视全树)持续维护索引。
|
|
229
|
+
* - 事件防抖 150ms;新增目录补扫子树;删除事件清行
|
|
230
|
+
* - 每 30s 写心跳;unref 保证 server 随 stdio 关闭退出
|
|
231
|
+
* - 失败(网络盘/权限)静默降级为纯走查模式(freshness.mode=walk)
|
|
232
|
+
*/
|
|
233
|
+
export function startWatcher(root) {
|
|
234
|
+
const abs = path.resolve(root);
|
|
235
|
+
const db = getIndex(abs);
|
|
236
|
+
let pending = 0;
|
|
237
|
+
const timers = new Map();
|
|
238
|
+
let closed = false;
|
|
239
|
+
const ignored = (rel) => {
|
|
240
|
+
const first = rel.split("/")[0];
|
|
241
|
+
return DEFAULT_IGNORE_DIRS.has(first) || first === ".pm";
|
|
242
|
+
};
|
|
243
|
+
const bumpPending = (d) => {
|
|
244
|
+
pending = Math.max(0, pending + d);
|
|
245
|
+
try {
|
|
246
|
+
setMeta(db, "pending", String(pending));
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* 库忙时持久化失败无所谓——内存计数仍准,meta 会随后续写收敛 */
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const process = (rel) => {
|
|
253
|
+
if (closed)
|
|
254
|
+
return;
|
|
255
|
+
try {
|
|
256
|
+
const full = path.join(abs, ...rel.split("/"));
|
|
257
|
+
const st = fs.statSync(full);
|
|
258
|
+
if (st.isDirectory()) {
|
|
259
|
+
// 新目录:补扫该子树(走 walkStatEntries 的 include 限定)
|
|
260
|
+
for (const e of walkStatEntries(abs, { include: [rel + "/**"] }, () => undefined)) {
|
|
261
|
+
const st2 = fs.statSync(path.join(abs, ...e.rel.split("/")));
|
|
262
|
+
upsertFile(abs, db, e.rel, st2, computeEntry(abs, e.rel, st2));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else if (st.isFile()) {
|
|
266
|
+
if (ignored(rel))
|
|
267
|
+
return;
|
|
268
|
+
upsertFile(abs, db, rel, st, computeEntry(abs, rel, st));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// 消失了 → 删行
|
|
273
|
+
try {
|
|
274
|
+
deleteFile(db, rel);
|
|
275
|
+
deleteSubtree(db, rel);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* ignore */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
bumpPending(-1);
|
|
283
|
+
try {
|
|
284
|
+
setMeta(db, "lastEvent", new Date().toISOString());
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
/* ignore */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
let watcher;
|
|
292
|
+
try {
|
|
293
|
+
watcher = fs.watch(abs, { recursive: true }, (_event, filename) => {
|
|
294
|
+
if (closed || !filename)
|
|
295
|
+
return;
|
|
296
|
+
let rel;
|
|
297
|
+
try {
|
|
298
|
+
rel = normSep(String(filename));
|
|
299
|
+
if (ignored(rel))
|
|
300
|
+
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);
|
|
314
|
+
try {
|
|
315
|
+
process(rel);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
bumpPending(-1);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
timers.set(rel, { timer: setTimeout(run, 150), due: Date.now() + 150, run });
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
setMeta(db, "mode", "walk");
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
watcher.unref();
|
|
329
|
+
// 每次 watcher 启动都换一个会话代次。只有本次会话建立后的精确走查
|
|
330
|
+
// 会把 lastWalkSession 对齐,因此停机窗口内的增删改不可能被旧心跳掩盖。
|
|
331
|
+
setMeta(db, "watcherSession", randomUUID());
|
|
332
|
+
setMeta(db, "pending", "0");
|
|
333
|
+
setMeta(db, "mode", "watcher");
|
|
334
|
+
let stopped = false;
|
|
335
|
+
activeWatchers.set(abs, { timers, closed: () => stopped });
|
|
336
|
+
const beat = setInterval(() => {
|
|
337
|
+
if (closed)
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
setMeta(db, "lastBeat", new Date().toISOString());
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
/* ignore */
|
|
344
|
+
}
|
|
345
|
+
}, 10_000);
|
|
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
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
setMeta(db, "mode", "walk");
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
/* 库可能已被关闭(closeIndex 先行)——telemetry 失败无所谓 */
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/** 巡检/诊断用:索引概况一行 */
|
|
373
|
+
export function indexSummary(root) {
|
|
374
|
+
const f = freshness(root);
|
|
375
|
+
if (f.files === 0)
|
|
376
|
+
return "索引:空(未走查)";
|
|
377
|
+
const age = f.lastWalk ? `,走查于 ${f.lastWalk.slice(0, 16).replace("T", " ")}` : "";
|
|
378
|
+
const beat = f.fresh ? ",watcher 保鲜中" : `(watcher ${f.mode === "watcher" ? "心跳过期" : "未运行"},下次审计将全量走查)`;
|
|
379
|
+
return `索引:${f.files} 文件${age}${beat}`;
|
|
380
|
+
}
|
|
381
|
+
export { isInitialized };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { resolveRoot, isInitialized } from "./paths.js";
|
|
5
|
+
import { registerAllTools } from "./tools.js";
|
|
6
|
+
import { loadRoadmap, loadTasks } from "./store.js";
|
|
7
|
+
import { renderRoadmap } from "./roadmap.js";
|
|
8
|
+
import { buildChangelog } from "./dashboard.js";
|
|
9
|
+
import { dashboardFile, pmPath } from "./paths.js";
|
|
10
|
+
import { startWatcher } from "./index-store.js";
|
|
11
|
+
import { auditGovernance } from "./governance-audit.js";
|
|
12
|
+
import { buildPortfolioReport, loadPortfolioProject } from "./portfolio.js";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { listAcceptanceBaselines } from "./acceptance-tools.js";
|
|
16
|
+
import { AcceptanceReportSchema } from "./acceptance-report.js";
|
|
17
|
+
/* --root 参数 > PM_ROOT 环境变量 > 启动时工作目录 */
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
let explicitRoot;
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
if (argv[i] === "--root" && argv[i + 1])
|
|
22
|
+
explicitRoot = argv[i + 1];
|
|
23
|
+
}
|
|
24
|
+
const root = resolveRoot(explicitRoot);
|
|
25
|
+
const server = new McpServer({ name: "pm-mcp", version: "0.1.1" });
|
|
26
|
+
registerAllTools(server, root);
|
|
27
|
+
/* -------------------------------- Resources ------------------------------- */
|
|
28
|
+
function safeText(fn) {
|
|
29
|
+
try {
|
|
30
|
+
return fn();
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
return `(不可用: ${e.message})`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
server.registerResource("dashboard", "pm://dashboard", { description: "项目仪表盘(与仓库根 PROJECT.md 同源),路线图/健康摘要/任务/功能清单" }, (uri) => ({
|
|
37
|
+
contents: [
|
|
38
|
+
{
|
|
39
|
+
uri: uri.href,
|
|
40
|
+
mimeType: "text/markdown",
|
|
41
|
+
text: safeText(() => fs.readFileSync(dashboardFile(root), "utf8")),
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}));
|
|
45
|
+
server.registerResource("roadmap", "pm://roadmap", { description: "路线图(里程碑进度 + 活跃里程碑任务明细)" }, (uri) => ({
|
|
46
|
+
contents: [
|
|
47
|
+
{
|
|
48
|
+
uri: uri.href,
|
|
49
|
+
mimeType: "text/markdown",
|
|
50
|
+
text: safeText(() => renderRoadmap(loadRoadmap(root).milestones, loadTasks(root).tasks, 2).join("\n")),
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
}));
|
|
54
|
+
server.registerResource("tasks", "pm://tasks", { description: "任务清单原始数据(tasks.json;超 100 条截断,完整数据用 list_tasks 过滤)" }, (uri) => ({
|
|
55
|
+
contents: [
|
|
56
|
+
{
|
|
57
|
+
uri: uri.href,
|
|
58
|
+
mimeType: "application/json",
|
|
59
|
+
text: safeText(() => {
|
|
60
|
+
const tasks = loadTasks(root).tasks;
|
|
61
|
+
// 资源也要守 token 预算:全量 dump 大账本会吃爆上下文(截断后仍是合法 JSON)
|
|
62
|
+
if (tasks.length <= 100)
|
|
63
|
+
return JSON.stringify(tasks, null, 2);
|
|
64
|
+
const shown = tasks.slice(0, 100);
|
|
65
|
+
shown.push({
|
|
66
|
+
id: `_TRUNCATED_${tasks.length - 100}`,
|
|
67
|
+
title: `…另有 ${tasks.length - 100} 个任务已截断,用 list_tasks 按状态/里程碑过滤获取`,
|
|
68
|
+
});
|
|
69
|
+
return JSON.stringify(shown, null, 2);
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
}));
|
|
74
|
+
server.registerResource("changelog", "pm://changelog", { description: "变更日志(由 sessions.json 生成的人类可读版本)" }, (uri) => ({
|
|
75
|
+
contents: [
|
|
76
|
+
{
|
|
77
|
+
uri: uri.href,
|
|
78
|
+
mimeType: "text/markdown",
|
|
79
|
+
text: safeText(() => buildChangelog(root)),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
}));
|
|
83
|
+
server.registerResource("architecture", "pm://architecture", { description: "跨文件/模块/语言治理审计:语义覆盖、依赖边界、接口、循环与质量矩阵" }, (uri) => ({
|
|
84
|
+
contents: [{ uri: uri.href, mimeType: "text/markdown", text: safeText(() => auditGovernance(root, 150).report) }],
|
|
85
|
+
}));
|
|
86
|
+
server.registerResource("portfolio", "pm://portfolio", { description: "当前项目的组合视图与跨仓依赖/版本风险" }, (uri) => ({
|
|
87
|
+
contents: [{
|
|
88
|
+
uri: uri.href,
|
|
89
|
+
mimeType: "application/json",
|
|
90
|
+
text: safeText(() => JSON.stringify(buildPortfolioReport({ projects: [loadPortfolioProject(root)], projectsRequested: 1 }), null, 2)),
|
|
91
|
+
}],
|
|
92
|
+
}));
|
|
93
|
+
server.registerResource("acceptance", "pm://acceptance", { description: "标准化产品验收概览:版本化预批准基线与最近一次机器判定报告" }, (uri) => ({
|
|
94
|
+
contents: [{
|
|
95
|
+
uri: uri.href,
|
|
96
|
+
mimeType: "application/json",
|
|
97
|
+
text: safeText(() => {
|
|
98
|
+
const reportDir = pmPath(root, "acceptance", "reports");
|
|
99
|
+
let latestReport = null;
|
|
100
|
+
if (fs.existsSync(reportDir)) {
|
|
101
|
+
const candidates = fs.readdirSync(reportDir, { withFileTypes: true })
|
|
102
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
103
|
+
.map((entry) => ({ name: entry.name, mtime: fs.statSync(path.join(reportDir, entry.name)).mtimeMs }))
|
|
104
|
+
.sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name));
|
|
105
|
+
if (candidates[0])
|
|
106
|
+
latestReport = AcceptanceReportSchema.parse(JSON.parse(fs.readFileSync(path.join(reportDir, candidates[0].name), "utf8")));
|
|
107
|
+
}
|
|
108
|
+
return JSON.stringify({ baselines: listAcceptanceBaselines(root), latest_report: latestReport }, null, 2);
|
|
109
|
+
}),
|
|
110
|
+
}],
|
|
111
|
+
}));
|
|
112
|
+
/* --------------------------------- Prompts -------------------------------- */
|
|
113
|
+
server.registerPrompt("start-session", { description: "开工仪式:恢复项目上下文,从断点续做,不靠回忆。" }, () => ({
|
|
114
|
+
messages: [
|
|
115
|
+
{
|
|
116
|
+
role: "user",
|
|
117
|
+
content: {
|
|
118
|
+
type: "text",
|
|
119
|
+
text: [
|
|
120
|
+
"按以下顺序恢复项目上下文(本项目由 pm-mcp 管理,状态以 .pm/ 为准,不要凭记忆陈述):",
|
|
121
|
+
"1. 调用 get_status:了解阶段、里程碑进度、进行中任务及其 checkpoint 下一步。",
|
|
122
|
+
"2. 若要动的模块不清楚,先 search_code 定位到具体文件与行,再读文件——三段式定位,不要全量读代码。",
|
|
123
|
+
"3. 陈述本次计划:要做哪个任务、动哪些文件、验收标准是什么。",
|
|
124
|
+
"4. 过程纪律:走捷径就 add_task(type=debt) 登记债务;修 bug 完成后 log_debug 记根因;长任务随手 checkpoint。",
|
|
125
|
+
].join("\n"),
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}));
|
|
130
|
+
server.registerPrompt("end-session", { description: "收工仪式:更新任务与里程碑、留断点、记会话——把本次上下文写回仓库。" }, () => ({
|
|
131
|
+
messages: [
|
|
132
|
+
{
|
|
133
|
+
role: "user",
|
|
134
|
+
content: {
|
|
135
|
+
type: "text",
|
|
136
|
+
text: [
|
|
137
|
+
"收工前依次完成(顺序执行,缺一不可):",
|
|
138
|
+
"1. 完成的任务:update_task 置 done,必须写 result_note(做了什么),feature/fix 类补 verification(用什么测试/命令证明)。",
|
|
139
|
+
"2. 未完成的任务:checkpoint 保存「进展 + 下一步具体动作」。",
|
|
140
|
+
"3. 本次落地的功能:register_feature 登记(入口文件 + 测试文件)。",
|
|
141
|
+
"4. 修过的 bug:log_debug 记录症状/根因/修法/验证。",
|
|
142
|
+
"5. 走过的捷径:add_task(type=debt) 登记债务,别让它在沉默中腐烂。",
|
|
143
|
+
"6. log_session 记录会话摘要与改动文件清单(如实列出,波及面统计靠它)。",
|
|
144
|
+
"7. 若到达里程碑节点:update_milestone 流转状态;可 snapshot_codebase 拍快照 + audit_structure 对账。",
|
|
145
|
+
].join("\n"),
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
}));
|
|
150
|
+
server.registerPrompt("onboard", { description: "入职简报工作流:引导客户端为新成员(人或 AI)生成项目现状一页纸。" }, () => ({
|
|
151
|
+
messages: [
|
|
152
|
+
{
|
|
153
|
+
role: "user",
|
|
154
|
+
content: {
|
|
155
|
+
type: "text",
|
|
156
|
+
text: [
|
|
157
|
+
"为新加入的成员(人或 AI)生成入职简报,数据全部来自 .pm/ 状态,不要编造:",
|
|
158
|
+
"1. 读 pm://dashboard(或 PROJECT.md):项目是什么、路线图到哪、健康状态如何。",
|
|
159
|
+
"2. list_decisions + 阅读最近 3 条 ADR:理解关键取舍的「为什么」。",
|
|
160
|
+
"3. list_features:了解已有功能地图与各功能入口文件。",
|
|
161
|
+
"4. get_status:当前焦点与断点。",
|
|
162
|
+
"5. 读 pm://architecture 与 pm://portfolio:模块 owner、公开接口、跨语言质量矩阵和跨仓版本风险。",
|
|
163
|
+
"输出一页纸简报:项目一句话 / 技术栈与模块 / 路线图现状 / 关键决策与红线 / 模块与接口边界 / 当前焦点与下一步 / 哪里有坑(债务、安全、语义与跨仓风险)。",
|
|
164
|
+
].join("\n"),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
}));
|
|
169
|
+
server.registerPrompt("architecture-review", { description: "跨文件/模块/语言架构评审:先取证,再判断影响和门禁。" }, () => ({
|
|
170
|
+
messages: [{
|
|
171
|
+
role: "user",
|
|
172
|
+
content: {
|
|
173
|
+
type: "text",
|
|
174
|
+
text: [
|
|
175
|
+
"按证据完成跨模块/跨语言架构评审:",
|
|
176
|
+
"1. 读取 pm://architecture 或调用 audit_governance,先检查语义覆盖、unresolved、owner、私有接口、循环和边界违规。",
|
|
177
|
+
"2. 对本次变更调用 impact_analysis,列出反向依赖文件与模块;未知文件不得忽略。",
|
|
178
|
+
"3. 调用 plan_quality_matrix;这一步只是计划,不能声称测试通过。",
|
|
179
|
+
"4. 只有用户明确要求执行时才调用 run_quality_matrix(confirm_execute=true),并分别报告每种语言的真实结果。",
|
|
180
|
+
"5. 跨仓变更读取 pm://portfolio,核对依赖目标、版本约束、加载失败与仓库循环。",
|
|
181
|
+
].join("\n"),
|
|
182
|
+
},
|
|
183
|
+
}],
|
|
184
|
+
}));
|
|
185
|
+
server.registerPrompt("acceptance-review", { description: "标准化终验:冻结质量基线后执行证据核验,不能把开发者自测冒充认证。" }, () => ({
|
|
186
|
+
messages: [{
|
|
187
|
+
role: "user",
|
|
188
|
+
content: {
|
|
189
|
+
type: "text",
|
|
190
|
+
text: [
|
|
191
|
+
"按 pm-mcp 的标准化验收闭环执行,不得事后调低阈值:",
|
|
192
|
+
"1. list_acceptance_baselines 并读取目标基线,确认 ISO/IEC 25010 九特性适用性、量化指标、风险和测试双向追踪。",
|
|
193
|
+
"2. 只有产品负责人审阅 draft fingerprint 后,才能 approve_acceptance_baseline;批准后的同版本不可修改。",
|
|
194
|
+
"3. 执行基线定义的质量、治理、性能与安全测试;失败、跳过、缺工具和过期证据都不得写成通过。",
|
|
195
|
+
"4. evaluate_acceptance 会重算项目内证据 SHA-256,并由机器生成不可覆盖的 JSON/Markdown 报告。",
|
|
196
|
+
"5. 分开陈述:第一方范围内验收、独立评价、托管 CI、生产证据和 ISO 认证;任何一层都不能冒充另一层。",
|
|
197
|
+
].join("\n"),
|
|
198
|
+
},
|
|
199
|
+
}],
|
|
200
|
+
}));
|
|
201
|
+
/* ---------------------------------- 启动 ---------------------------------- */
|
|
202
|
+
await server.connect(new StdioServerTransport());
|
|
203
|
+
// 超大项目核心:server 常驻期间用递归 watcher 持续保鲜 SQLite 索引,
|
|
204
|
+
// audit/快照稳态下免全量走查(失败自动降级为纯走查模式)
|
|
205
|
+
if (isInitialized(root)) {
|
|
206
|
+
const watcher = startWatcher(root);
|
|
207
|
+
console.error(`[pm-mcp] watcher: ${watcher ? "active" : "unavailable(降级为按需全量走查)"}`);
|
|
208
|
+
}
|
|
209
|
+
// stdio 是协议通道,日志只能走 stderr
|
|
210
|
+
console.error(`[pm-mcp] ready. project root: ${root}`);
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { saveDebugLog, saveFeatures, saveFileNotes, saveProject, saveRoadmap, saveSessions, saveTasks, } from "./store.js";
|
|
3
|
+
import { ensurePmDirs, isInitialized, pmPath } from "./paths.js";
|
|
4
|
+
import { now } from "./types.js";
|
|
5
|
+
import { governancePath, saveGovernance } from "./governance-model.js";
|
|
6
|
+
/** 初始化 .pm/ 状态目录(幂等防护:已初始化则报错) */
|
|
7
|
+
export function initProject(root, input) {
|
|
8
|
+
if (isInitialized(root)) {
|
|
9
|
+
throw new Error(`项目已初始化(${pmPath(root, "project.json")} 已存在)。修改信息请用 update_project。`);
|
|
10
|
+
}
|
|
11
|
+
ensurePmDirs(root);
|
|
12
|
+
const t = now();
|
|
13
|
+
const project = {
|
|
14
|
+
name: input.name,
|
|
15
|
+
description: input.description ?? "",
|
|
16
|
+
stack: input.stack ?? [],
|
|
17
|
+
goals: input.goals ?? [],
|
|
18
|
+
phase: "",
|
|
19
|
+
modules: input.modules ?? [],
|
|
20
|
+
exposure: input.exposure ?? "local",
|
|
21
|
+
license: input.license ?? "",
|
|
22
|
+
budgets: { maxFileLoc: 500, refactorQuotaPct: 20, sessionBlastRadius: 15, outputBudgetLines: 150 },
|
|
23
|
+
created: t,
|
|
24
|
+
updated: t,
|
|
25
|
+
};
|
|
26
|
+
saveProject(root, project);
|
|
27
|
+
saveRoadmap(root, { seq: 0, milestones: [] });
|
|
28
|
+
saveTasks(root, { seq: 0, tasks: [] });
|
|
29
|
+
saveFeatures(root, { seq: 0, features: [] });
|
|
30
|
+
saveSessions(root, { seq: 0, sessions: [] });
|
|
31
|
+
saveDebugLog(root, { seq: 0, entries: [] });
|
|
32
|
+
saveFileNotes(root, { notes: {} });
|
|
33
|
+
if (!fs.existsSync(governancePath(root)))
|
|
34
|
+
saveGovernance(root, {});
|
|
35
|
+
const secFile = pmPath(root, "security.json");
|
|
36
|
+
if (!fs.existsSync(secFile)) {
|
|
37
|
+
fs.writeFileSync(secFile, JSON.stringify({ findings: [], last_scan: null }, null, 2) + "\n", "utf8");
|
|
38
|
+
}
|
|
39
|
+
return project;
|
|
40
|
+
}
|