@luckychen1993/pm-mcp 0.1.3 → 0.1.5

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,9 @@ 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";
18
+ import { recordUsage } from "./tool-base.js";
19
+ import { logRuntime } from "./usage-log.js";
17
20
  /* --root 参数 > PM_ROOT 环境变量 > 启动时工作目录 */
18
21
  const argv = process.argv.slice(2);
19
22
  let explicitRoot;
@@ -22,7 +25,7 @@ for (let i = 0; i < argv.length; i++) {
22
25
  explicitRoot = argv[i + 1];
23
26
  }
24
27
  const root = resolveRoot(explicitRoot);
25
- const server = new McpServer({ name: "pm-mcp", version: "0.1.3" }, {
28
+ const server = new McpServer({ name: "pm-mcp", version: "0.1.5" }, {
26
29
  instructions: [
27
30
  "多 Agent 规则:相同业务的所有写工具调用必须携带相同 idempotency_key(建议 task-id:operation);同键同参数只执行一次,同键不同参数会被拒绝。",
28
31
  "完全相同的并行读请求会由服务端合并并复用结果;不要让多个分支重复请求同一工具和参数。",
@@ -39,30 +42,42 @@ function safeText(fn) {
39
42
  return `(不可用: ${e.message})`;
40
43
  }
41
44
  }
42
- server.registerResource("dashboard", "pm://dashboard", { description: "项目仪表盘(与仓库根 PROJECT.md 同源),路线图/健康摘要/任务/功能清单" }, (uri) => ({
45
+ async function resourceText(name, fn) {
46
+ const started = Date.now();
47
+ try {
48
+ const text = (await runCoalescedRead(root, `resource:${name}`, {}, () => safeText(fn))).text;
49
+ recordUsage(root, `resource:${name}`, "read", started, { ok: true, text });
50
+ return text;
51
+ }
52
+ catch (error) {
53
+ recordUsage(root, `resource:${name}`, "read", started, { ok: false, error: error.message });
54
+ throw error;
55
+ }
56
+ }
57
+ server.registerResource("dashboard", "pm://dashboard", { description: "项目仪表盘(与仓库根 PROJECT.md 同源),路线图/健康摘要/任务/功能清单" }, async (uri) => ({
43
58
  contents: [
44
59
  {
45
60
  uri: uri.href,
46
61
  mimeType: "text/markdown",
47
- text: safeText(() => fs.readFileSync(dashboardFile(root), "utf8")),
62
+ text: await resourceText("dashboard", () => fs.readFileSync(dashboardFile(root), "utf8")),
48
63
  },
49
64
  ],
50
65
  }));
51
- server.registerResource("roadmap", "pm://roadmap", { description: "路线图(里程碑进度 + 活跃里程碑任务明细)" }, (uri) => ({
66
+ server.registerResource("roadmap", "pm://roadmap", { description: "路线图(里程碑进度 + 活跃里程碑任务明细)" }, async (uri) => ({
52
67
  contents: [
53
68
  {
54
69
  uri: uri.href,
55
70
  mimeType: "text/markdown",
56
- text: safeText(() => renderRoadmap(loadRoadmap(root).milestones, loadTasks(root).tasks, 2).join("\n")),
71
+ text: await resourceText("roadmap", () => renderRoadmap(loadRoadmap(root).milestones, loadTasks(root).tasks, 2).join("\n")),
57
72
  },
58
73
  ],
59
74
  }));
60
- server.registerResource("tasks", "pm://tasks", { description: "任务清单原始数据(tasks.json;超 100 条截断,完整数据用 list_tasks 过滤)" }, (uri) => ({
75
+ server.registerResource("tasks", "pm://tasks", { description: "任务清单原始数据(tasks.json;超 100 条截断,完整数据用 list_tasks 过滤)" }, async (uri) => ({
61
76
  contents: [
62
77
  {
63
78
  uri: uri.href,
64
79
  mimeType: "application/json",
65
- text: safeText(() => {
80
+ text: await resourceText("tasks", () => {
66
81
  const tasks = loadTasks(root).tasks;
67
82
  // 资源也要守 token 预算:全量 dump 大账本会吃爆上下文(截断后仍是合法 JSON)
68
83
  if (tasks.length <= 100)
@@ -77,30 +92,30 @@ server.registerResource("tasks", "pm://tasks", { description: "任务清单原
77
92
  },
78
93
  ],
79
94
  }));
80
- server.registerResource("changelog", "pm://changelog", { description: "变更日志(由 sessions.json 生成的人类可读版本)" }, (uri) => ({
95
+ server.registerResource("changelog", "pm://changelog", { description: "变更日志(由 sessions.json 生成的人类可读版本)" }, async (uri) => ({
81
96
  contents: [
82
97
  {
83
98
  uri: uri.href,
84
99
  mimeType: "text/markdown",
85
- text: safeText(() => buildChangelog(root)),
100
+ text: await resourceText("changelog", () => buildChangelog(root)),
86
101
  },
87
102
  ],
88
103
  }));
89
- server.registerResource("architecture", "pm://architecture", { description: "跨文件/模块/语言治理审计:语义覆盖、依赖边界、接口、循环与质量矩阵" }, (uri) => ({
90
- contents: [{ uri: uri.href, mimeType: "text/markdown", text: safeText(() => auditGovernance(root, 150).report) }],
104
+ server.registerResource("architecture", "pm://architecture", { description: "跨文件/模块/语言治理审计:语义覆盖、依赖边界、接口、循环与质量矩阵" }, async (uri) => ({
105
+ contents: [{ uri: uri.href, mimeType: "text/markdown", text: await resourceText("architecture", () => auditGovernance(root, 150).report) }],
91
106
  }));
92
- server.registerResource("portfolio", "pm://portfolio", { description: "当前项目的组合视图与跨仓依赖/版本风险" }, (uri) => ({
107
+ server.registerResource("portfolio", "pm://portfolio", { description: "当前项目的组合视图与跨仓依赖/版本风险" }, async (uri) => ({
93
108
  contents: [{
94
109
  uri: uri.href,
95
110
  mimeType: "application/json",
96
- text: safeText(() => JSON.stringify(buildPortfolioReport({ projects: [loadPortfolioProject(root)], projectsRequested: 1 }), null, 2)),
111
+ text: await resourceText("portfolio", () => JSON.stringify(buildPortfolioReport({ projects: [loadPortfolioProject(root)], projectsRequested: 1 }), null, 2)),
97
112
  }],
98
113
  }));
99
- server.registerResource("acceptance", "pm://acceptance", { description: "标准化产品验收概览:版本化预批准基线与最近一次机器判定报告" }, (uri) => ({
114
+ server.registerResource("acceptance", "pm://acceptance", { description: "标准化产品验收概览:版本化预批准基线与最近一次机器判定报告" }, async (uri) => ({
100
115
  contents: [{
101
116
  uri: uri.href,
102
117
  mimeType: "application/json",
103
- text: safeText(() => {
118
+ text: await resourceText("acceptance", () => {
104
119
  const reportDir = pmPath(root, "acceptance", "reports");
105
120
  let latestReport = null;
106
121
  if (fs.existsSync(reportDir)) {
@@ -209,8 +224,11 @@ await server.connect(new StdioServerTransport());
209
224
  // 超大项目核心:server 常驻期间用递归 watcher 持续保鲜 SQLite 索引,
210
225
  // audit/快照稳态下免全量走查(失败自动降级为纯走查模式)
211
226
  if (isInitialized(root)) {
227
+ ensurePmRuntimeIgnored(root);
212
228
  const watcher = startWatcher(root);
213
- console.error(`[pm-mcp] watcher: ${watcher ? "active" : "unavailable(降级为按需全量走查)"}`);
229
+ console.error(`[pm-mcp] watcher coordinator: ${watcher ? "active(同项目单 leader)" : "unavailable(降级为按需全量走查)"}`);
230
+ logRuntime(root, watcher ? "info" : "warn", watcher ? "watcher.active" : "watcher.unavailable", watcher ? undefined : "降级为按需全量走查");
214
231
  }
232
+ logRuntime(root, "info", "server.ready", `pid=${process.pid} node=${process.versions.node}`);
215
233
  // stdio 是协议通道,日志只能走 stderr
216
234
  console.error(`[pm-mcp] ready. project root: ${root}`);
package/dist/init.js CHANGED
@@ -3,6 +3,7 @@ import { saveDebugLog, saveFeatures, saveFileNotes, saveProject, saveRoadmap, sa
3
3
  import { ensurePmDirs, isInitialized, pmPath } from "./paths.js";
4
4
  import { now } from "./types.js";
5
5
  import { governancePath, saveGovernance } from "./governance-model.js";
6
+ import { upsertAgentsMd } from "./agents-md.js";
6
7
  /** 初始化 .pm/ 状态目录(幂等防护:已初始化则报错) */
7
8
  export function initProject(root, input) {
8
9
  if (isInitialized(root)) {
@@ -36,5 +37,7 @@ export function initProject(root, input) {
36
37
  if (!fs.existsSync(secFile)) {
37
38
  fs.writeFileSync(secFile, JSON.stringify({ findings: [], last_scan: null }, null, 2) + "\n", "utf8");
38
39
  }
40
+ // 项目开启即有规矩:AGENTS.md 自动写入(已有则增量合并,绝不覆盖既有内容)
41
+ upsertAgentsMd(root, input.agentsMd !== false);
39
42
  return project;
40
43
  }
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*", "usage-log*.jsonl*", "runtime-log*.jsonl*"];
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
  }
@@ -8,6 +8,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
10
  import { budgetLines, toolI, toolR, toolW } from "./tool-base.js";
11
+ import { renderRuntimeLogLines, renderUsageLogLines } from "./usage-log.js";
11
12
  function taskLine(t, withNext = false) {
12
13
  const parts = [`[${t.status}]`, t.id, t.title, `(${t.type}${t.milestone ? `,${t.milestone}` : ""})`];
13
14
  if (withNext && t.checkpoint) {
@@ -16,7 +17,7 @@ function taskLine(t, withNext = false) {
16
17
  return parts.join(" ");
17
18
  }
18
19
  export function registerProjectTools(server, root) {
19
- toolW(server, root, "init_project", "初始化当前项目的 .pm/ 状态目录(单一事实来源)。每个新项目只调用一次;已初始化请用 update_project。", {
20
+ toolW(server, root, "init_project", "初始化当前项目的 .pm/ 状态目录(单一事实来源),并自动写入 AGENTS.md 工作规矩(已存在则增量合并,不覆盖你的内容)。每个新项目只调用一次;已初始化请用 update_project。", {
20
21
  name: z.string().min(1).describe("项目名"),
21
22
  description: z.string().optional().describe("一句话描述"),
22
23
  stack: z.array(z.string()).optional().describe("技术栈,如 [\"TypeScript\",\"React\"]"),
@@ -24,13 +25,15 @@ export function registerProjectTools(server, root) {
24
25
  license: z.string().optional().describe("项目许可证(SPDX 名,如 MIT)"),
25
26
  exposure: z.enum(["local", "network", "public"]).optional().describe("暴露面,影响安全告警力度"),
26
27
  modules: z.array(z.string()).optional().describe("初始模块登记"),
28
+ agents_md: z.boolean().optional().describe("默认 true:自动写入/合并 AGENTS.md 工作规矩(false 关闭)"),
27
29
  }, (args) => {
28
- const project = initProject(root, args);
30
+ const project = initProject(root, { ...args, agentsMd: args.agents_md });
29
31
  refreshDerived(root);
30
32
  return [
31
33
  `✅ 项目已初始化: ${project.name}`,
32
34
  `状态目录: ${pmPath(root, "")}(随 git 提交,团队/AI 共享)`,
33
35
  "仪表盘已生成: PROJECT.md",
36
+ args.agents_md === false ? "AGENTS.md 规矩:按要求跳过(agents_md:false)" : "AGENTS.md 工作规矩已写入(已存在则合并;ZCode 每次打开该项目自动加载)",
34
37
  "建议顺序: upsert_module 声明模块/owner/语言 → add_milestone 建里程碑 → add_task 拆任务 → 开工前 get_status / 收工后 log_session。",
35
38
  ].join("\n");
36
39
  });
@@ -95,6 +98,24 @@ export function registerProjectTools(server, root) {
95
98
  L.push("> 三段式定位:本状态 → search_code 精确到行 → 才读具体文件。别全量读代码。");
96
99
  return foldLines(L, { maxLines: budgetLines(root) });
97
100
  });
101
+ toolR(server, root, "get_usage_log", "使用日志:pm-mcp 被用了哪些工具、成败、耗时,以及输出 token 估算与「折叠省下的 token」——回答“用它到底省不省 token”。只记工具名与计量,不记参数内容。日志滚动保留约 2MB。", {
102
+ last: z.number().int().min(1).max(200).optional().describe("返回最近 N 条明细(默认 20;汇总始终覆盖全部保留日志)"),
103
+ tool: z.string().optional().describe("只看某工具,如 get_status"),
104
+ ok: z.boolean().optional().describe("true 只看成,false 只看败"),
105
+ since: z.string().optional().describe("ISO 日期/时间,只看该时间之后的明细"),
106
+ }, (args) => {
107
+ requireInitialized(root);
108
+ return foldLines(renderUsageLogLines(root, args), { maxLines: budgetLines(root) });
109
+ });
110
+ toolR(server, root, "get_runtime_log", "运行日志:server 就绪、watcher 状态、工具报错等服务级事件(info/warn/error),判断 pm-mcp 运行是否健康、哪里在反复出错。", {
111
+ last: z.number().int().min(1).max(200).optional().describe("返回最近 N 条明细(默认 20)"),
112
+ level: z.enum(["info", "warn", "error"]).optional().describe("只看某级别"),
113
+ event: z.string().optional().describe("只看某事件,如 tool.error / server.ready"),
114
+ since: z.string().optional().describe("ISO 日期/时间,只看该时间之后的明细"),
115
+ }, (args) => {
116
+ requireInitialized(root);
117
+ return foldLines(renderRuntimeLogLines(root, args), { maxLines: budgetLines(root) });
118
+ });
98
119
  toolW(server, root, "update_project", "更新项目元信息/预算(描述、技术栈、目标、阶段、模块登记、许可证、暴露面、复杂度与输出预算)。", {
99
120
  description: z.string().optional(),
100
121
  stack: z.array(z.string()).optional(),
package/dist/search.js CHANGED
@@ -41,7 +41,7 @@ export function rgSearch(root, query, glob, maxResults, regex) {
41
41
  if (regex)
42
42
  args.push("-e", query);
43
43
  else
44
- args.push("-F", query);
44
+ args.push("-F", "-e", query);
45
45
  if (glob)
46
46
  args.push("--glob", glob);
47
47
  args.push(".");
@@ -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);