@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.
@@ -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,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.2" });
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
- 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) => ({
37
47
  contents: [
38
48
  {
39
49
  uri: uri.href,
40
50
  mimeType: "text/markdown",
41
- text: safeText(() => fs.readFileSync(dashboardFile(root), "utf8")),
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: 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")),
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: safeText(() => {
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: safeText(() => buildChangelog(root)),
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: 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) }],
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: 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)),
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: safeText(() => {
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}`);
@@ -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, tool, toolW } from "./tool-base.js";
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
- tool(server, "list_features", "列出功能清单(可按模块/状态过滤)。", { module: z.string().optional(), status: z.enum(["planned", "implemented", "deprecated"]).optional() }, (args) => {
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
- tool(server, "list_decisions", "列出全部架构决策(ADR)。", {}, () => {
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
- tool(server, "search_code", "在真实代码里检索(防幻觉事中防线):谈某个功能/文件之前先搜实码。返回 file:line 片段而非整文件(token 经济)。默认按字面量匹配;regex=true 时按 JavaScript 正则(仅在你确需时开启)。", {
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
- tool(server, "search_knowledge", "检索六类项目知识源(ADR/任务/功能/会话/调试记录/文件索引)——回答「之前为什么这么做」「上次这个错怎么解的」,用历史结论代替重新推理。代码内容请用 search_code。", { query: z.string().min(1) }, (args) => {
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
- 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
  }
@@ -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, tool, toolW } from "./tool-base.js";
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
- tool(server, "get_status", "一站式「我在哪」:项目阶段 + 里程碑进度 + 进行中任务(含断点下一步)+ 健康摘要 + 最近变更。会话开工第一件事。since 可只看某日期以来的任务/会话变化。", { since: z.string().optional().describe("ISO 日期,如 2026-09-01:只看该日期以来的任务/会话变化") }, (args) => {
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
- tool(server, "regenerate_dashboard", "手动重新生成 PROJECT.md 仪表盘与 changelog.md(正常情况下每次写操作已自动刷新)。", {}, () => {
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
- tool(server, "get_roadmap", "查看路线图。depth=1 每个里程碑一行摘要(功能再多也不乱);depth=2 额外展开活跃里程碑的任务明细与断点。", { depth: z.number().int().min(1).max(2).optional().describe("默认 1") }, (args) => {
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);