@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.
@@ -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
- const db = getIndex(abs);
243
+ let db = null;
236
244
  let pending = 0;
237
245
  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";
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
- setMeta(db, "pending", String(pending));
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
- /* 库忙时持久化失败无所谓——内存计数仍准,meta 会随后续写收敛 */
285
+ catch (error) {
286
+ recordWatcherError("persist pending", error);
250
287
  }
251
288
  };
252
289
  const process = (rel) => {
253
- if (closed)
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
- const st = fs.statSync(full);
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
- try {
274
- deleteFile(db, rel);
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, "lastEvent", new Date().toISOString());
352
+ setMeta(db, "pending", "0");
353
+ setMeta(db, "mode", "walk");
285
354
  }
286
- catch {
287
- /* ignore */
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
- 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))
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
- process(rel);
369
+ rel = normSep(String(filename));
370
+ if (ignored(rel))
371
+ return;
316
372
  }
317
373
  catch {
318
- bumpPending(-1);
374
+ return;
319
375
  }
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 */
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
- }, 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
- }
395
+ catch (error) {
363
396
  try {
364
397
  setMeta(db, "mode", "walk");
365
398
  }
366
- catch {
367
- /* 库可能已被关闭(closeIndex 先行)——telemetry 失败无所谓 */
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.3" }, {
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
- server.registerResource("dashboard", "pm://dashboard", { description: "项目仪表盘(与仓库根 PROJECT.md 同源),路线图/健康摘要/任务/功能清单" }, (uri) => ({
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: safeText(() => fs.readFileSync(dashboardFile(root), "utf8")),
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: safeText(() => renderRoadmap(loadRoadmap(root).milestones, loadTasks(root).tasks, 2).join("\n")),
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: safeText(() => {
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: safeText(() => buildChangelog(root)),
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: safeText(() => auditGovernance(root, 150).report) }],
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: safeText(() => JSON.stringify(buildPortfolioReport({ projects: [loadPortfolioProject(root)], projectsRequested: 1 }), null, 2)),
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: safeText(() => {
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
- ensureFresh(root);
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
- ensureFresh(root);
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.3";
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) {