@luckychen1993/pm-mcp 0.1.4 → 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.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 pm-mcp contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pm-mcp contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -32,6 +32,8 @@ AI 编码项目的典型失控(本工具逐一给出机制):
32
32
 
33
33
  ## 一个命令自动添加到 AI 编程助手
34
34
 
35
+ 本页安装示例固定使用 **0.1.5**,包含安全扫描、项目命名和字面量搜索修复。0.1.4 不支持 `setup --project`;验证这些能力时请确认实际运行版本为 0.1.5 或更新版本。
36
+
35
37
  要求:Node.js ≥ 22.18。Windows、macOS 和 Linux 使用同一条命令:
36
38
 
37
39
  ```bash
@@ -46,6 +48,16 @@ npx -y @luckychen1993/pm-mcp@latest setup --dry-run
46
48
 
47
49
  非标准安装路径无法自动发现时,可在同一命令末尾指定 `--client codex|claude|zcode|cursor|vscode`;`--client print` 输出通用 MCP JSON。首次启动由 `npx` 下载并在本机运行,pm-mcp 本身不需要 API Key,也不会调用远程模型。
48
50
 
51
+ ### 新项目一条命令上手(Codex 必用)
52
+
53
+ Codex 桌面版启动 MCP 服务时不带工作区路径,逐项目钉根才能用。在项目目录里跑一条命令,自动完成「注册钉定条目 + 初始化 .pm + 写 AGENTS.md 工作规矩」:
54
+
55
+ ```bash
56
+ npx -y @luckychen1993/pm-mcp@0.1.5 setup --client codex --project
57
+ ```
58
+
59
+ 跑完完全重启 Codex,新会话即可用(工具名前缀 `pm-mcp-<目录名>-<路径摘要>`)。不同中文目录和同名目录独立注册;重复执行会核对项目路径,匹配的旧版条目仍可复用。
60
+
49
61
  ### 客户端官方命令(自动检测失败时备用)
50
62
 
51
63
  #### Codex / ChatGPT 桌面版 / Codex IDE 扩展
@@ -53,7 +65,7 @@ npx -y @luckychen1993/pm-mcp@latest setup --dry-run
53
65
  Codex 的本地客户端共享同一份 MCP 配置。复制执行:
54
66
 
55
67
  ```bash
56
- codex mcp add pm-mcp -- npx -y @luckychen1993/pm-mcp@0.1.4
68
+ codex mcp add pm-mcp -- npx -y @luckychen1993/pm-mcp@0.1.5
57
69
  ```
58
70
 
59
71
  重启客户端后可用 `codex mcp list` 检查。该命令遵循 [OpenAI 官方 MCP CLI 格式](https://learn.chatgpt.com/docs/extend/mcp?surface=cli)。
@@ -61,7 +73,7 @@ codex mcp add pm-mcp -- npx -y @luckychen1993/pm-mcp@0.1.4
61
73
  #### Claude Code
62
74
 
63
75
  ```bash
64
- claude mcp add pm-mcp --scope user -- npx -y @luckychen1993/pm-mcp@0.1.4
76
+ claude mcp add pm-mcp --scope user -- npx -y @luckychen1993/pm-mcp@0.1.5
65
77
  ```
66
78
 
67
79
  #### ZCode、Cursor、VS Code(Windows 备用脚本)
@@ -69,7 +81,7 @@ claude mcp add pm-mcp --scope user -- npx -y @luckychen1993/pm-mcp@0.1.4
69
81
  将 `<client>` 替换为 `zcode`、`cursor` 或 `vscode`,整行复制到 PowerShell:
70
82
 
71
83
  ```powershell
72
- $u='https://raw.githubusercontent.com/happy520ai/pm-mcp/v0.1.4/install.ps1'; $f=Join-Path $env:TEMP 'install-pm-mcp.ps1'; Invoke-WebRequest $u -OutFile $f; & $f -Client <client>
84
+ $u='https://raw.githubusercontent.com/happy520ai/pm-mcp/v0.1.5/install.ps1'; $f=Join-Path $env:TEMP 'install-pm-mcp.ps1'; Invoke-WebRequest $u -OutFile $f; & $f -Client <client>
73
85
  ```
74
86
 
75
87
  脚本只写对应客户端的 MCP 配置;已有 JSON 配置会先生成带时间戳的备份。建议执行前先打开 [`install.ps1`](install.ps1) 审阅。ZCode 默认写入 `$ZCODE_HOME/cli/config.json` 或 `~/.zcode/cli/config.json`,Cursor 默认写入 `~/.cursor/mcp.json`;也可用 `-ConfigPath` 指定路径。
@@ -81,7 +93,7 @@ $u='https://raw.githubusercontent.com/happy520ai/pm-mcp/v0.1.4/install.ps1'; $f=
81
93
  "mcpServers": {
82
94
  "pm-mcp": {
83
95
  "command": "npx",
84
- "args": ["-y", "@luckychen1993/pm-mcp@0.1.4"],
96
+ "args": ["-y", "@luckychen1993/pm-mcp@0.1.5"],
85
97
  "env": {}
86
98
  }
87
99
  }
@@ -131,7 +143,7 @@ snapshot_codebase + audit_structure 对账;audit_security 安全体检;audit
131
143
 
132
144
  也可以直接用内置 prompts:`start-session` / `end-session` / `onboard` / `architecture-review` / `acceptance-review`;其中 `onboard` 会引导客户端读取状态并生成新人/AI 入职简报。
133
145
 
134
- ## 工具清单(46 个)
146
+ ## 工具清单(48 个)
135
147
 
136
148
  | 域 | 工具 | 说明 |
137
149
  |---|---|---|
@@ -146,6 +158,7 @@ snapshot_codebase + audit_structure 对账;audit_security 安全体检;audit
146
158
  | 审计 | `snapshot_codebase` / `audit_structure` | 结构快照;完整性/增长/漂移/债务/churn/复杂度/索引/足迹/测试/Git 十节对账 |
147
159
  | 安全 | `audit_security` / `list_findings` / `resolve_finding` | 密钥+危险模式+新增/通配依赖提示;扫描命中原文不落盘,处置 note 拒绝内置密钥形态 |
148
160
  | 法律 | `audit_license` | 依赖许可证清单、copyleft 冲突、GPL 头检测、LICENSE 检查、来源登记 |
161
+ | 可观测 | `get_usage_log` / `get_runtime_log` | 使用日志(工具调用统计、错误率、耗时、输出与折叠省下的 token 估算——验证省不省 token)与运行日志(server/watcher/工具报错);只记计量不记参数内容 |
149
162
  | 注册表 | `list_projects` | 本机所有被管理项目 |
150
163
  | 治理模型 | `init_governance` / `get_governance` / `upsert_module` / `upsert_interface` / `upsert_repository` / `set_governance_policies` | 结构化模块根、owner、语言、公开接口、允许/禁止依赖、跨仓版本约束与强制策略 |
151
164
  | 语义治理 | `discover_languages` / `audit_governance` / `impact_analysis` / `list_semantic_evidence` / `save_semantic_evidence` | 编译器/Tree-sitter AST 关系、hash-bound 原生分析器/运行时证据、循环/越界/unresolved/覆盖率与变更反向闭包 |
@@ -312,7 +325,7 @@ npm run gate # 完整本地门禁
312
325
  | 单元 | budget/store/roadmap/health/security/license/audit/search/dashboard | 各账本与机制的正确性(含伪造密钥/危险模式 fixture、已识别命中原文不落盘断言) |
313
326
  | 治理 | governance-model/language-adapters/semantic-graph/semantic-evidence/polyglot-ast/portfolio/governance-audit | 模块 schema、八语言 AST、symbol-bound call、原生/runtime证据、私有接口、循环、影响闭包、semver 与跨仓 fail-closed |
314
327
  | 标准化验收 | acceptance-evaluator/acceptance-tools/acceptance-gate/quality-evidence | 九特性/五阶段、冻结指标与断言、双向追踪、风险接受、源码/证据/报告 SHA-256、伪造 PASS 失败 |
315
- | 集成 | integration/governance-mcp/quality-gate | 真实拉起 stdio server 全链路:46 工具、7 资源、5 提示词;真实执行安全 argv 质量命令 |
328
+ | 集成 | integration/governance-mcp/quality-gate | 真实拉起 stdio server 全链路:48 工具、7 资源、5 提示词;真实执行安全 argv 质量命令 |
316
329
  | 场景 | scenario.test.ts | **对 dist 产物**跑三段会话生命周期:中文+空格路径、CRLF、断点跨进程恢复、故障注入(删测试/加 skip/删入口文件/新增依赖/伪造密钥/GPL 头)、三种根解析方式(cwd / PM_ROOT / --root)各用一遍 |
317
330
  | 完整性 + 真实仓库 | integrity.test.ts / realrepo.test.ts | 状态不变式(全账本 schema、派生逐字节一致、原子性、损坏恢复、500 任务规模折叠、并发双进程零丢失);并以本仓库真实 `.pm` 为被测对象(漂移为零、PROJECT.md 同步、done 纪律、全仓拷贝审计不误报) |
318
331
  | 钻空(红队) | exploit.test.ts | 每个反制机制配一个「先钻、再断言钻不进」的测试(见上表) |
@@ -0,0 +1,51 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * AGENTS.md 工作规矩:init_project 时自动写入/合并("项目开启即有规矩")。
5
+ * - 文件不存在 → 创建
6
+ * - 已存在且无标记段 → 末尾追加(绝不覆盖你的内容)
7
+ * - 已存在且有标记段 → 原样保留(幂等;模板升级时用新版本号标记替换)
8
+ */
9
+ const BEGIN = "<!-- pm-mcp:agents-md:v1:begin -->";
10
+ const END = "<!-- pm-mcp:agents-md:v1:end -->";
11
+ const SECTION = `${BEGIN}
12
+ # 项目管理规矩(pm-mcp 自动维护段落,可整体删除但不建议)
13
+
14
+ 本项目由 pm-mcp 管理,状态以 .pm/ 目录为准,禁止凭记忆陈述项目状态。
15
+
16
+ ## 开工(每次会话第一件事)
17
+ 1. get_status:了解阶段、里程碑、进行中任务与其 checkpoint 下一步
18
+ 2. 不清楚的模块先 search_code 定位到 file:line 再读文件,不要全量读代码
19
+ 3. 陈述本次计划:做哪个任务、动哪些文件、验收标准
20
+
21
+ ## 做事过程
22
+ - 长任务:add_task 时给 steps;上下文快满或中断前 checkpoint 存断点
23
+ - 走捷径:立刻 add_task(type=debt) 登记债务,不许无声欠债
24
+ - 修 bug:完成后 log_debug 记录症状/根因/修法/验证
25
+ - 陈述「某功能在某文件」之前,先 search_code 求证
26
+
27
+ ## 收工(缺一不可)
28
+ 1. 完成的任务 update_task 置 done + result_note;feature/fix 补 verification
29
+ 2. 未完成任务 checkpoint(进展 + 下一步具体动作)
30
+ 3. 落地的功能 register_feature(入口文件 + 测试文件)
31
+ 4. log_session 如实记录改动文件清单
32
+
33
+ ## 每个里程碑节点
34
+ snapshot_codebase + audit_structure 对账;audit_security 安全体检;audit_license 许可证审计
35
+ ${END}`;
36
+ /** 幂等 upsert:返回发生了什么(skipped = 调用方显式关闭) */
37
+ export function upsertAgentsMd(root, enabled = true) {
38
+ if (!enabled)
39
+ return "skipped";
40
+ const file = path.join(root, "AGENTS.md");
41
+ if (!fs.existsSync(file)) {
42
+ fs.writeFileSync(file, SECTION + "\n", "utf8");
43
+ return "created";
44
+ }
45
+ const content = fs.readFileSync(file, "utf8");
46
+ if (content.includes(BEGIN))
47
+ return "unchanged";
48
+ const separator = content.endsWith("\n") ? "" : "\n";
49
+ fs.writeFileSync(file, content + separator + "\n" + SECTION + "\n", "utf8");
50
+ return "appended";
51
+ }
package/dist/budget.js CHANGED
@@ -1,4 +1,32 @@
1
1
  import { relative as pathRelative } from "node:path";
2
+ import { estimateTokens } from "./usage-log.js";
3
+ /**
4
+ * token 经济:读类工具输出硬预算。
5
+ * 超出预算自动折叠为 "Top N + 另有 M 项已折叠",引导模型用过滤参数缩小范围。
6
+ * 折叠/截断省下的字符与 token 会记账,供使用日志统计「省了多少 token」。
7
+ */
8
+ let foldSavedChars = 0;
9
+ let foldSavedTokens = 0;
10
+ function noteFoldSavings(full, out) {
11
+ const savedChars = full.length - out.length;
12
+ if (savedChars <= 0)
13
+ return;
14
+ foldSavedChars += savedChars;
15
+ foldSavedTokens += Math.max(0, estimateTokens(full) - estimateTokens(out));
16
+ }
17
+ /**
18
+ * 取走自上次调用以来折叠/截断省下的字符与 token(估算)。
19
+ * 单进程内计数:并发异步调用之间的归因可能漂移,但总量精确(每次折叠恰好记一次)。
20
+ * 工具包装器在业务函数返回后调用,把省量归到本次工具调用。
21
+ */
22
+ export function drainFoldSavings() {
23
+ if (foldSavedChars <= 0)
24
+ return null;
25
+ const out = { chars: foldSavedChars, tokens: foldSavedTokens };
26
+ foldSavedChars = 0;
27
+ foldSavedTokens = 0;
28
+ return out;
29
+ }
2
30
  /** 限制字符串单行长度,超长截断(防止一行巨长文件吃 token) */
3
31
  export function capLine(line, maxChars = 300) {
4
32
  if (line.length <= maxChars)
@@ -12,8 +40,11 @@ export function capLine(line, maxChars = 300) {
12
40
  export function foldLines(lines, opts) {
13
41
  const { maxLines, hint } = opts;
14
42
  const capped = lines.map((l) => capLine(l));
15
- if (capped.length <= maxLines)
16
- return capped.join("\n");
43
+ if (capped.length <= maxLines) {
44
+ const out = capped.join("\n");
45
+ noteFoldSavings(lines.join("\n"), out);
46
+ return out;
47
+ }
17
48
  const keep = Math.max(1, Math.floor(maxLines * 0.8));
18
49
  const tail = Math.max(0, maxLines - keep - 1);
19
50
  const hidden = capped.length - keep - tail;
@@ -21,7 +52,9 @@ export function foldLines(lines, opts) {
21
52
  parts.push(`…(另有 ${hidden} 项已折叠${hint ? "," + hint : ""},请用过滤参数缩小范围)`);
22
53
  if (tail > 0)
23
54
  parts.push(...capped.slice(-tail));
24
- return parts.join("\n");
55
+ const out = parts.join("\n");
56
+ noteFoldSavings(lines.join("\n"), out);
57
+ return out;
25
58
  }
26
59
  /** 对整段文本按行折叠 */
27
60
  export function foldText(text, maxLines, hint) {
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ import path from "node:path";
15
15
  import { listAcceptanceBaselines } from "./acceptance-tools.js";
16
16
  import { AcceptanceReportSchema } from "./acceptance-report.js";
17
17
  import { runCoalescedRead } from "./idempotency.js";
18
+ import { recordUsage } from "./tool-base.js";
19
+ import { logRuntime } from "./usage-log.js";
18
20
  /* --root 参数 > PM_ROOT 环境变量 > 启动时工作目录 */
19
21
  const argv = process.argv.slice(2);
20
22
  let explicitRoot;
@@ -23,7 +25,7 @@ for (let i = 0; i < argv.length; i++) {
23
25
  explicitRoot = argv[i + 1];
24
26
  }
25
27
  const root = resolveRoot(explicitRoot);
26
- const server = new McpServer({ name: "pm-mcp", version: "0.1.4" }, {
28
+ const server = new McpServer({ name: "pm-mcp", version: "0.1.5" }, {
27
29
  instructions: [
28
30
  "多 Agent 规则:相同业务的所有写工具调用必须携带相同 idempotency_key(建议 task-id:operation);同键同参数只执行一次,同键不同参数会被拒绝。",
29
31
  "完全相同的并行读请求会由服务端合并并复用结果;不要让多个分支重复请求同一工具和参数。",
@@ -41,7 +43,16 @@ function safeText(fn) {
41
43
  }
42
44
  }
43
45
  async function resourceText(name, fn) {
44
- return (await runCoalescedRead(root, `resource:${name}`, {}, () => safeText(fn))).text;
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
+ }
45
56
  }
46
57
  server.registerResource("dashboard", "pm://dashboard", { description: "项目仪表盘(与仓库根 PROJECT.md 同源),路线图/健康摘要/任务/功能清单" }, async (uri) => ({
47
58
  contents: [
@@ -216,6 +227,8 @@ if (isInitialized(root)) {
216
227
  ensurePmRuntimeIgnored(root);
217
228
  const watcher = startWatcher(root);
218
229
  console.error(`[pm-mcp] watcher coordinator: ${watcher ? "active(同项目单 leader)" : "unavailable(降级为按需全量走查)"}`);
230
+ logRuntime(root, watcher ? "info" : "warn", watcher ? "watcher.active" : "watcher.unavailable", watcher ? undefined : "降级为按需全量走查");
219
231
  }
232
+ logRuntime(root, "info", "server.ready", `pid=${process.pid} node=${process.versions.node}`);
220
233
  // stdio 是协议通道,日志只能走 stderr
221
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/paths.js CHANGED
@@ -57,7 +57,7 @@ export function ensurePmRuntimeIgnored(root) {
57
57
  fs.mkdirSync(directory, { recursive: true });
58
58
  fs.mkdirSync(path.join(directory, ".runtime", "idempotency"), { recursive: true });
59
59
  const file = path.join(directory, ".gitignore");
60
- const required = [".lock", ".runtime/", "benchmarks/", "index.db*"];
60
+ const required = [".lock", ".runtime/", "benchmarks/", "index.db*", "usage-log*.jsonl*", "runtime-log*.jsonl*"];
61
61
  for (let attempt = 0; attempt < 3; attempt += 1) {
62
62
  const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
63
63
  const lines = current.split(/\r?\n/).map((line) => line.trim());
@@ -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(".");
package/dist/security.js CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { createHash } from "node:crypto";
2
4
  import { loadExtraRules, loadProject, loadSecurity, saveSecurity } from "./store.js";
3
5
  import { latestSnapshot } from "./audit.js";
@@ -69,8 +71,37 @@ function findSecretRule(value) {
69
71
  function fingerprint(ruleId, rel, matched) {
70
72
  return createHash("sha1").update(ruleId + "|" + rel + "|" + matched).digest("hex").slice(0, 16);
71
73
  }
74
+ /** 只有根内普通路径的 ENOENT 才证明删除;权限错误、目录和链接均不证明修复。 */
75
+ function findingFileState(root, rel) {
76
+ const parts = rel.replace(/\\/g, "/").split("/");
77
+ if (path.isAbsolute(rel) || parts.some((part) => !part || part === "." || part === ".."))
78
+ return "unknown";
79
+ let current = root;
80
+ let stat;
81
+ for (const part of parts) {
82
+ current = path.join(current, part);
83
+ try {
84
+ stat = fs.lstatSync(current);
85
+ if (stat.isSymbolicLink())
86
+ return "unknown";
87
+ }
88
+ catch (error) {
89
+ return error.code === "ENOENT" ? "missing" : "unknown";
90
+ }
91
+ }
92
+ return stat?.isFile() ? "file" : "unknown";
93
+ }
72
94
  function scanDetections(root, forceContent = false, indexPrepared = false) {
73
95
  const out = [];
96
+ // 只跟踪已有 open 发现,不为百万文件仓库额外物化整棵扫描覆盖表。
97
+ const pending = loadSecurity(root).findings.filter((finding) => finding.status === "open");
98
+ const byFile = new Map();
99
+ for (const finding of pending) {
100
+ const entries = byFile.get(finding.file) ?? [];
101
+ entries.push(finding);
102
+ byFile.set(finding.file, entries);
103
+ }
104
+ const checkedFindings = new Map();
74
105
  const rules = [...SECRET_PATTERNS, ...DANGEROUS_PATTERNS];
75
106
  // 用户自定义规则
76
107
  for (const r of loadExtraRules(root).rules) {
@@ -113,6 +144,7 @@ function scanDetections(root, forceContent = false, indexPrepared = false) {
113
144
  const content = readText(root, rel, forceContent);
114
145
  if (content === null)
115
146
  continue;
147
+ const contentDigest = byFile.has(rel) ? createHash("sha256").update(content).digest("hex") : undefined;
116
148
  const lines = content.split("\n");
117
149
  for (const rule of rules) {
118
150
  if (rule.glob && !rule.glob.test(rel))
@@ -131,9 +163,18 @@ function scanDetections(root, forceContent = false, indexPrepared = false) {
131
163
  message: rule.message,
132
164
  });
133
165
  }
166
+ for (const finding of byFile.get(rel) ?? []) {
167
+ if (finding.rule === rule.id)
168
+ checkedFindings.set(finding.fingerprint, contentDigest);
169
+ }
170
+ }
171
+ }
172
+ for (const finding of pending) {
173
+ if (!checkedFindings.has(finding.fingerprint) && findingFileState(root, finding.file) === "missing") {
174
+ checkedFindings.set(finding.fingerprint, null);
134
175
  }
135
176
  }
136
- return out;
177
+ return { detections: out, checkedFindings };
137
178
  }
138
179
  /** 昂贵的全仓扫描阶段;调用方可在账本锁外完成,再把脱敏发现提交到账本。 */
139
180
  export function prepareSecurityAudit(root, options = {}) {
@@ -141,13 +182,14 @@ export function prepareSecurityAudit(root, options = {}) {
141
182
  const forceContent = options.forceContent ?? true;
142
183
  if (forceIndex)
143
184
  walkRefresh(root, { forceContent: true });
144
- return { detections: scanDetections(root, forceContent, forceIndex || options.indexPrepared === true) };
185
+ return scanDetections(root, forceContent, forceIndex || options.indexPrepared === true);
145
186
  }
146
187
  /** 扫描并更新台账(幂等:重复扫描不产生重复条目) */
147
188
  export function auditSecurity(root, prepared) {
148
189
  const project = loadProject(root);
149
190
  const stamp = now();
150
- const detections = prepared?.detections ?? scanDetections(root);
191
+ const scan = prepared ?? scanDetections(root);
192
+ const detections = scan.detections;
151
193
  const security = loadSecurity(root);
152
194
  const existing = new Map(security.findings.map((f) => [f.fingerprint, f]));
153
195
  const acceptedRefound = [];
@@ -186,10 +228,25 @@ export function auditSecurity(root, prepared) {
186
228
  });
187
229
  }
188
230
  }
189
- // 不再检出且仍 open 的 → 自动转 fixed(附注说明)
231
+ // 没有命中不等于已修复:必须证明该旧发现的文件/规则已检查,或文件确已删除。
190
232
  let autoFixed = 0;
233
+ let unverified = 0;
234
+ const currentDigests = new Map();
191
235
  for (const f of security.findings) {
192
236
  if (f.status === "open" && !seen.has(f.fingerprint)) {
237
+ const expected = scan.checkedFindings?.get(f.fingerprint);
238
+ let confirmed = expected === null && findingFileState(root, f.file) === "missing";
239
+ if (typeof expected === "string") {
240
+ if (!currentDigests.has(f.file)) {
241
+ const content = findingFileState(root, f.file) === "file" ? readText(root, f.file, true) : null;
242
+ currentDigests.set(f.file, content === null ? null : createHash("sha256").update(content).digest("hex"));
243
+ }
244
+ confirmed = currentDigests.get(f.file) === expected;
245
+ }
246
+ if (!confirmed) {
247
+ unverified += 1;
248
+ continue;
249
+ }
193
250
  f.status = "fixed";
194
251
  f.note += "(自动:最近一次扫描未再检出)";
195
252
  autoFixed += 1;
@@ -236,7 +293,9 @@ export function auditSecurity(root, prepared) {
236
293
  }
237
294
  }
238
295
  if (autoFixed > 0)
239
- L.push(`自动关闭 ${autoFixed} 个(代码中已不再出现)。`);
296
+ L.push(`自动关闭 ${autoFixed} 个(已成功重扫未命中或确认文件删除)。`);
297
+ if (unverified > 0)
298
+ L.push(`⚠️ 扫描覆盖不足:${unverified} 个旧发现未获得完整检查证据,保持未解决状态(检查文件大小、可读性、扫描范围和规则)。`);
240
299
  if (newDeps.length > 0)
241
300
  L.push(`⚠️ 上次快照后新增依赖: ${newDeps.join(", ")}(AI 引入的依赖请审查来源与必要性)`);
242
301
  if (riskyDeps.length > 0)
package/dist/setup.js CHANGED
@@ -2,11 +2,15 @@ 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.4";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { initProject } from "./init.js";
7
+ import { isInitialized } from "./paths.js";
8
+ import { refreshDerived } from "./dashboard.js";
9
+ export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.5";
6
10
  export const SERVER_NAME = "pm-mcp";
7
11
  const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode"];
8
12
  export function parseSetupArgs(argv) {
9
- const options = { client: "auto", force: false, dryRun: false, help: false };
13
+ const options = { client: "auto", force: false, dryRun: false, help: false, project: false };
10
14
  for (let index = 0; index < argv.length; index += 1) {
11
15
  const arg = argv[index];
12
16
  if (arg === "--client") {
@@ -17,6 +21,14 @@ export function parseSetupArgs(argv) {
17
21
  options.client = value;
18
22
  index += 1;
19
23
  }
24
+ else if (arg === "--project") {
25
+ options.project = true;
26
+ const value = argv[index + 1];
27
+ if (value && !value.startsWith("--")) {
28
+ options.projectDir = value;
29
+ index += 1;
30
+ }
31
+ }
20
32
  else if (arg === "--force") {
21
33
  options.force = true;
22
34
  }
@@ -82,7 +94,7 @@ export function mergeJsonServer(document, shape) {
82
94
  }
83
95
  function backupName(file) {
84
96
  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "");
85
- return `${file}.backup-${stamp}-${process.pid}`;
97
+ return `${file}.backup-${stamp}-${process.pid}-${randomUUID()}`;
86
98
  }
87
99
  export function writeJsonClientConfig(file, shape, dryRun = false) {
88
100
  const absolute = path.resolve(file);
@@ -146,6 +158,86 @@ function appendCodexConfig(file, dryRun, log) {
146
158
  const block = `${separator}\n[mcp_servers.${SERVER_NAME}]\ncommand = "npx"\nargs = ["-y", "${PACKAGE_SPEC}"]\n`;
147
159
  fs.appendFileSync(absolute, block, "utf8");
148
160
  }
161
+ function canonicalProjectRoot(projectRoot) {
162
+ const absolute = path.resolve(projectRoot);
163
+ let resolved;
164
+ try {
165
+ resolved = fs.realpathSync.native(absolute);
166
+ }
167
+ catch (error) {
168
+ if (error.code !== "ENOENT")
169
+ throw error;
170
+ resolved = absolute;
171
+ }
172
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
173
+ }
174
+ function legacyProjectServerKey(projectRoot) {
175
+ const base = path.basename(projectRoot);
176
+ const safe = base.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
177
+ return `${SERVER_NAME}-${safe || "project"}`;
178
+ }
179
+ /** 完整规范路径参与命名,区分中文目录和位于不同父目录下的同名项目。 */
180
+ export function codexProjectServerKey(projectRoot) {
181
+ const canonical = canonicalProjectRoot(projectRoot);
182
+ return `${legacyProjectServerKey(canonical)}-${createHash("sha256").update(canonical).digest("hex").slice(0, 12)}`;
183
+ }
184
+ /** 只解析本安装器生成的单行 JSON 兼容 args;手写复杂 TOML 不猜测其绑定。 */
185
+ function projectEntry(text, server) {
186
+ const header = new RegExp(`^\\s*\\[mcp_servers\\.(?:${server}|"${server}"|'${server}')\\]\\s*(?:#.*)?$`, "m").exec(text);
187
+ if (!header)
188
+ return undefined;
189
+ const tail = text.slice(header.index + header[0].length);
190
+ const nextHeader = tail.search(/^\s*\[/m);
191
+ const section = nextHeader < 0 ? tail : tail.slice(0, nextHeader);
192
+ const argsLine = /^\s*args\s*=\s*(\[[^\r\n]*\])\s*(?:#.*)?$/m.exec(section);
193
+ try {
194
+ const args = argsLine ? JSON.parse(argsLine[1]) : undefined;
195
+ if (!Array.isArray(args) || !args.every((value) => typeof value === "string"))
196
+ return {};
197
+ const positions = args.flatMap((value, index) => value === "--root" ? [index] : []);
198
+ if (positions.length !== 1 || !path.isAbsolute(args[positions[0] + 1] ?? ""))
199
+ return {};
200
+ return { root: canonicalProjectRoot(args[positions[0] + 1]) };
201
+ }
202
+ catch {
203
+ return {};
204
+ }
205
+ }
206
+ /**
207
+ * --project 模式:把指定目录作为钉定项目写进 Codex config.toml(npx + --root)。
208
+ * 桌面版 Codex 起 MCP 服务时 cwd 不在工作区,必须逐项目钉根;同名条目已存在则跳过(fail-closed,不产生重复 TOML 表头)。
209
+ */
210
+ export function appendCodexProjectConfig(file, projectRoot, force, dryRun, log) {
211
+ const absolute = path.resolve(file);
212
+ const server = codexProjectServerKey(projectRoot);
213
+ const canonical = canonicalProjectRoot(projectRoot);
214
+ const existing = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
215
+ const entry = projectEntry(existing, server);
216
+ const legacy = legacyProjectServerKey(canonical);
217
+ const legacyEntry = projectEntry(existing, legacy);
218
+ if (entry && entry.root !== canonical) {
219
+ throw new Error(`已有 [mcp_servers.${server}] 指向不同项目或无法确认 --root;未修改配置。`);
220
+ }
221
+ if (entry || legacyEntry?.root === canonical) {
222
+ const reused = entry ? server : legacy;
223
+ if (!force)
224
+ log(`[skip] Codex already has [mcp_servers.${reused}] for this project; nothing to change.`);
225
+ else
226
+ throw new Error(`Codex already has [mcp_servers.${reused}]; 请先手工删除该段再 --force 重加。`);
227
+ return;
228
+ }
229
+ log(`[${dryRun ? "plan" : "write"}] Codex: ${absolute}`);
230
+ if (dryRun)
231
+ return;
232
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
233
+ if (fs.existsSync(absolute))
234
+ fs.copyFileSync(absolute, backupName(absolute), fs.constants.COPYFILE_EXCL);
235
+ const tomlRoot = path.resolve(projectRoot).replace(/\\/g, "/");
236
+ const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
237
+ const block = `${separator}\n[mcp_servers.${server}]\ncommand = "npx"\nargs = ["-y", ${JSON.stringify(PACKAGE_SPEC)}, "--root", ${JSON.stringify(tomlRoot)}]\n`;
238
+ fs.appendFileSync(absolute, block, "utf8");
239
+ log(`[entry] [mcp_servers.${server}] → --root ${tomlRoot}`);
240
+ }
149
241
  function run(command, args, visible) {
150
242
  const result = spawnSync(command, args, {
151
243
  shell: false,
@@ -177,8 +269,12 @@ function configureCliClient(client, options, available, log) {
177
269
  throw new Error(`${client} could not add ${SERVER_NAME}`);
178
270
  return true;
179
271
  }
180
- function configureClient(client, options, context, log) {
272
+ function configureClient(client, options, context, log, projectRoot) {
181
273
  if (client === "codex") {
274
+ if (options.project && projectRoot) {
275
+ appendCodexProjectConfig(codexConfigPath(context.env, context.home), projectRoot, options.force, options.dryRun, log);
276
+ return;
277
+ }
182
278
  if (configureCliClient("codex", options, context.commandExists("codex"), log))
183
279
  return;
184
280
  appendCodexConfig(codexConfigPath(context.env, context.home), options.dryRun, log);
@@ -207,8 +303,9 @@ function configureClient(client, options, context, log) {
207
303
  function printHelp(log) {
208
304
  log("pm-mcp setup — configure local AI coding clients");
209
305
  log("");
210
- log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|print] [--force] [--dry-run]`);
306
+ log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|print] [--project [dir]] [--force] [--dry-run]`);
211
307
  log("Default auto mode configures every detected supported client.");
308
+ log("--project [dir]: 在 Codex 把该目录注册为钉定项目并初始化 .pm(新项目一条命令上手;默认当前目录)");
212
309
  }
213
310
  export function runSetup(argv, overrides = {}) {
214
311
  const options = parseSetupArgs(argv);
@@ -226,24 +323,42 @@ export function runSetup(argv, overrides = {}) {
226
323
  const context = {
227
324
  env,
228
325
  home,
326
+ cwd: overrides.cwd,
229
327
  commandExists: overrides.commandExists ?? ((command) => commandExists(command, env)),
230
328
  exists: overrides.exists ?? fs.existsSync,
231
329
  };
330
+ const projectRoot = options.project ? path.resolve(options.projectDir?.trim() || context.cwd || process.cwd()) : undefined;
232
331
  const selected = options.client === "all"
233
332
  ? CLIENTS
234
333
  : options.client === "auto"
235
334
  ? detectClients(context)
236
335
  : [options.client];
237
- if (selected.length === 0) {
336
+ if (selected.length === 0 && !options.project) {
238
337
  log("No supported client was detected. Use --client <name>, or --client print for generic JSON.");
239
338
  return 1;
240
339
  }
241
340
  log(`pm-mcp setup ${PACKAGE_SPEC}`);
242
- log(`clients: ${selected.join(", ")}`);
341
+ if (selected.length > 0)
342
+ log(`clients: ${selected.join(", ")}`);
343
+ if (options.project && projectRoot) {
344
+ const name = path.basename(projectRoot) || "project";
345
+ log(`project: ${projectRoot}`);
346
+ if (options.dryRun) {
347
+ log(`[plan] init_project ${name}(.pm/ + PROJECT.md + AGENTS.md 工作规矩)`);
348
+ }
349
+ else if (isInitialized(projectRoot)) {
350
+ log("[skip] 项目已初始化(.pm/project.json 已存在)。");
351
+ }
352
+ else {
353
+ initProject(projectRoot, { name });
354
+ refreshDerived(projectRoot);
355
+ log(`[init] ✅ ${name} 已初始化:.pm/ 状态目录 + PROJECT.md 仪表盘 + AGENTS.md 工作规矩`);
356
+ }
357
+ }
243
358
  let failures = 0;
244
359
  for (const client of selected) {
245
360
  try {
246
- configureClient(client, options, context, log);
361
+ configureClient(client, options, context, log, projectRoot);
247
362
  }
248
363
  catch (error) {
249
364
  failures += 1;
@@ -252,6 +367,11 @@ export function runSetup(argv, overrides = {}) {
252
367
  }
253
368
  if (failures > 0)
254
369
  return 1;
255
- log("Done. Restart the configured client and ask it to call pm-mcp get_status.");
370
+ if (options.project && selected.includes("codex")) {
371
+ log("完成。请完全退出并重启 Codex,开新会话即可使用(工具名前缀 pm-mcp-<项目名>)。");
372
+ }
373
+ else {
374
+ log("Done. Restart the configured client and ask it to call pm-mcp get_status.");
375
+ }
256
376
  return 0;
257
377
  }
package/dist/tool-base.js CHANGED
@@ -1,14 +1,49 @@
1
1
  import { loadProject } from "./store.js";
2
2
  import { IdempotencyKeySchema, runCoalescedRead, runIdempotentWrite, runIdempotentWriteSync } from "./idempotency.js";
3
+ import { drainFoldSavings } from "./budget.js";
4
+ import { estimateTokens, logRuntime, logUsage } from "./usage-log.js";
5
+ /** 工具/资源调用结束后写使用日志(含本次折叠省下的 token 估算);绝不抛错。 */
6
+ export function recordUsage(root, tool, kind, started, outcome) {
7
+ try {
8
+ const folded = drainFoldSavings();
9
+ logUsage(root, {
10
+ ts: new Date().toISOString(),
11
+ tool,
12
+ kind,
13
+ ok: outcome.ok,
14
+ ms: Date.now() - started,
15
+ ...(outcome.text !== undefined ? { out_chars: outcome.text.length, est_tokens: estimateTokens(outcome.text) } : {}),
16
+ ...(folded ? { folded_chars: folded.chars, folded_tokens: folded.tokens } : {}),
17
+ ...(outcome.replayed ? { replayed: true } : {}),
18
+ ...(outcome.pending ? { pending: true } : {}),
19
+ ...(outcome.uncertain ? { uncertain: true } : {}),
20
+ ...(outcome.error ? { error: outcome.error.slice(0, 300) } : {}),
21
+ });
22
+ }
23
+ catch {
24
+ /* 使用日志绝不影响工具调用 */
25
+ }
26
+ }
3
27
  /** 只读工具:跨 Agent/进程合并完全相同的并行请求,短窗复用结果。 */
4
28
  export function toolR(server, root, name, description, schema, fn) {
5
29
  server.registerTool(name, { description, inputSchema: schema, annotations: { readOnlyHint: true } }, (async (args) => {
30
+ const started = Date.now();
6
31
  try {
7
32
  const result = await runCoalescedRead(root, name, args, fn);
33
+ recordUsage(root, name, "read", started, {
34
+ ok: !result.pending,
35
+ text: result.text,
36
+ replayed: result.replayed,
37
+ pending: result.pending,
38
+ uncertain: result.uncertain,
39
+ });
8
40
  return { content: [{ type: "text", text: result.text }] };
9
41
  }
10
42
  catch (error) {
11
- return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
43
+ const message = error.message;
44
+ recordUsage(root, name, "read", started, { ok: false, error: message });
45
+ logRuntime(root, "error", "tool.error", `${name}: ${message.slice(0, 200)}`);
46
+ return { content: [{ type: "text", text: `错误: ${message}` }], isError: true };
12
47
  }
13
48
  }));
14
49
  }
@@ -22,26 +57,50 @@ function writeSchema(schema) {
22
57
  /** 同步写工具:业务幂等占位 + 跨进程账本锁覆盖完整读-改-写。 */
23
58
  export function toolW(server, root, name, description, schema, fn, prepare) {
24
59
  server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, ((args) => {
60
+ const started = Date.now();
25
61
  try {
26
62
  const result = runIdempotentWriteSync(root, name, args, fn, prepare);
27
63
  const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
28
- return { content: [{ type: "text", text: prefix + result.text }], ...(result.pending ? { isError: true } : {}) };
64
+ const text = prefix + result.text;
65
+ recordUsage(root, name, "write", started, {
66
+ ok: !result.pending,
67
+ text,
68
+ replayed: result.replayed,
69
+ pending: result.pending,
70
+ uncertain: result.uncertain,
71
+ });
72
+ return { content: [{ type: "text", text }], ...(result.pending ? { isError: true } : {}) };
29
73
  }
30
74
  catch (error) {
31
- return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
75
+ const message = error.message;
76
+ recordUsage(root, name, "write", started, { ok: false, error: message });
77
+ logRuntime(root, "error", "tool.error", `${name}: ${message.slice(0, 200)}`);
78
+ return { content: [{ type: "text", text: `错误: ${message}` }], isError: true };
32
79
  }
33
80
  }));
34
81
  }
35
82
  /** 已自带内部锁或异步执行的写工具:先跨进程占位,完成后缓存结果。 */
36
83
  export function toolI(server, root, name, description, schema, fn) {
37
84
  server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, (async (args) => {
85
+ const started = Date.now();
38
86
  try {
39
87
  const result = await runIdempotentWrite(root, name, args, fn);
40
88
  const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
41
- return { content: [{ type: "text", text: prefix + result.text }], ...(result.pending ? { isError: true } : {}) };
89
+ const text = prefix + result.text;
90
+ recordUsage(root, name, "write-async", started, {
91
+ ok: !result.pending,
92
+ text,
93
+ replayed: result.replayed,
94
+ pending: result.pending,
95
+ uncertain: result.uncertain,
96
+ });
97
+ return { content: [{ type: "text", text }], ...(result.pending ? { isError: true } : {}) };
42
98
  }
43
99
  catch (error) {
44
- return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
100
+ const message = error.message;
101
+ recordUsage(root, name, "write-async", started, { ok: false, error: message });
102
+ logRuntime(root, "error", "tool.error", `${name}: ${message.slice(0, 200)}`);
103
+ return { content: [{ type: "text", text: `错误: ${message}` }], isError: true };
45
104
  }
46
105
  }));
47
106
  }
@@ -0,0 +1,278 @@
1
+ import fs from "node:fs";
2
+ import { isInitialized, pmPath } from "./paths.js";
3
+ /**
4
+ * 使用日志 + 运行日志(运行时数据,非账本):
5
+ * - usage-log.jsonl:每次 MCP 工具/资源调用一条(工具名、成败、耗时、输出与折叠省下的 token 估算)。
6
+ * 只记计量不记参数内容,避免敏感信息与大参数进日志。
7
+ * - runtime-log.jsonl:服务级事件(server 就绪、watcher 状态、工具报错、轮转)。
8
+ * 均为追加式 JSONL,超限自动轮转(当前 + .1 共两份),绝不抛错——日志永远不能影响工具调用本身。
9
+ */
10
+ export const USAGE_LOG = "usage-log.jsonl";
11
+ export const RUNTIME_LOG = "runtime-log.jsonl";
12
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
13
+ const ROTATE_CHECK_EVERY = 32;
14
+ const rotateCheckCounter = new Map();
15
+ function maxLogBytes() {
16
+ const override = Number(process.env.PM_TEST_LOG_MAX_BYTES);
17
+ return Number.isFinite(override) && override > 0 ? override : DEFAULT_MAX_BYTES;
18
+ }
19
+ function rotatedFile(file) {
20
+ return file.replace(/\.jsonl$/, ".1.jsonl");
21
+ }
22
+ function appendLine(root, file, line) {
23
+ try {
24
+ if (!isInitialized(root))
25
+ return;
26
+ fs.appendFileSync(file, line + "\n", "utf8");
27
+ // 每 N 次写查一次大小(测试覆盖了轮转,此时每次都查)
28
+ const every = process.env.PM_TEST_LOG_MAX_BYTES ? 1 : ROTATE_CHECK_EVERY;
29
+ const n = (rotateCheckCounter.get(file) ?? 0) + 1;
30
+ rotateCheckCounter.set(file, n);
31
+ if (n % every !== 0)
32
+ return;
33
+ if (fs.statSync(file).size <= maxLogBytes())
34
+ return;
35
+ const rotated = rotatedFile(file);
36
+ try {
37
+ fs.rmSync(rotated, { force: true });
38
+ fs.renameSync(file, rotated);
39
+ }
40
+ catch {
41
+ // 多进程并发轮转竞争失败可接受:当前文件继续追加,下次再试
42
+ }
43
+ }
44
+ catch {
45
+ /* 日志绝不影响工具调用 */
46
+ }
47
+ }
48
+ export function logUsage(root, entry) {
49
+ appendLine(root, pmPath(root, USAGE_LOG), JSON.stringify(entry));
50
+ }
51
+ export function logRuntime(root, level, event, detail) {
52
+ const entry = { ts: new Date().toISOString(), level, event, ...(detail ? { detail } : {}) };
53
+ appendLine(root, pmPath(root, RUNTIME_LOG), JSON.stringify(entry));
54
+ }
55
+ /** 粗估 token 数:CJK 每字≈1 token,其余≈4字符/token(混合中英文经验值,够做趋势判断) */
56
+ export function estimateTokens(text) {
57
+ let cjk = 0;
58
+ for (const ch of text) {
59
+ const c = ch.codePointAt(0);
60
+ if ((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) ||
61
+ (c >= 0x3000 && c <= 0x303f) || (c >= 0xff00 && c <= 0xffef))
62
+ cjk += 1;
63
+ }
64
+ return cjk + Math.ceil((text.length - cjk) / 4);
65
+ }
66
+ function readJsonl(root, file, coerce) {
67
+ const out = [];
68
+ for (const target of [rotatedFile(file), file]) {
69
+ let raw;
70
+ try {
71
+ raw = fs.readFileSync(target, "utf8");
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ for (const line of raw.split("\n")) {
77
+ const trimmed = line.trim();
78
+ if (!trimmed)
79
+ continue;
80
+ try {
81
+ const parsed = coerce(JSON.parse(trimmed));
82
+ if (parsed !== null)
83
+ out.push(parsed);
84
+ }
85
+ catch {
86
+ /* 坏行跳过(轮转竞争/手工编辑残留) */
87
+ }
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ function asString(v) {
93
+ return typeof v === "string" ? v : undefined;
94
+ }
95
+ function coerceUsage(raw) {
96
+ const tool = asString(raw.tool);
97
+ const ts = asString(raw.ts);
98
+ if (!tool || !ts)
99
+ return null;
100
+ return {
101
+ ts,
102
+ tool,
103
+ kind: raw.kind === "write" || raw.kind === "write-async" ? raw.kind : "read",
104
+ ok: raw.ok === true,
105
+ ms: typeof raw.ms === "number" && Number.isFinite(raw.ms) ? raw.ms : 0,
106
+ ...(typeof raw.out_chars === "number" ? { out_chars: raw.out_chars } : {}),
107
+ ...(typeof raw.est_tokens === "number" ? { est_tokens: raw.est_tokens } : {}),
108
+ ...(typeof raw.folded_chars === "number" ? { folded_chars: raw.folded_chars } : {}),
109
+ ...(typeof raw.folded_tokens === "number" ? { folded_tokens: raw.folded_tokens } : {}),
110
+ ...(raw.replayed === true ? { replayed: true } : {}),
111
+ ...(raw.pending === true ? { pending: true } : {}),
112
+ ...(raw.uncertain === true ? { uncertain: true } : {}),
113
+ ...(asString(raw.error) ? { error: asString(raw.error) } : {}),
114
+ };
115
+ }
116
+ function coerceRuntime(raw) {
117
+ const ts = asString(raw.ts);
118
+ const event = asString(raw.event);
119
+ const level = raw.level === "info" || raw.level === "warn" || raw.level === "error" ? raw.level : null;
120
+ if (!ts || !event || !level)
121
+ return null;
122
+ return { ts, level, event, ...(asString(raw.detail) ? { detail: asString(raw.detail) } : {}) };
123
+ }
124
+ /** 读全部保留的使用日志(轮转份 + 当前份,按时间序) */
125
+ export function loadUsageEntries(root) {
126
+ return readJsonl(root, pmPath(root, USAGE_LOG), coerceUsage);
127
+ }
128
+ export function loadRuntimeEntries(root) {
129
+ return readJsonl(root, pmPath(root, RUNTIME_LOG), coerceRuntime);
130
+ }
131
+ export function usageSummary(entries) {
132
+ const calls = entries.length;
133
+ const errors = entries.filter((e) => !e.ok).length;
134
+ const totalMs = entries.reduce((sum, e) => sum + e.ms, 0);
135
+ const slowest = entries.reduce((best, e) => (best === null || e.ms > best.ms ? e : best), null);
136
+ const estTokensOut = entries.reduce((sum, e) => sum + (e.est_tokens ?? 0), 0);
137
+ const estTokensSaved = entries.reduce((sum, e) => sum + (e.folded_tokens ?? 0), 0);
138
+ const byTool = new Map();
139
+ for (const e of entries) {
140
+ const bucket = byTool.get(e.tool) ?? { calls: 0, errors: 0, ms: 0 };
141
+ bucket.calls += 1;
142
+ if (!e.ok)
143
+ bucket.errors += 1;
144
+ bucket.ms += e.ms;
145
+ byTool.set(e.tool, bucket);
146
+ }
147
+ const topTools = [...byTool.entries()]
148
+ .map(([tool, b]) => ({ tool, calls: b.calls, errors: b.errors, avgMs: Math.round(b.ms / Math.max(1, b.calls)) }))
149
+ .sort((a, b) => b.calls - a.calls || a.tool.localeCompare(b.tool))
150
+ .slice(0, 8);
151
+ return {
152
+ calls,
153
+ errors,
154
+ errorRate: calls === 0 ? 0 : errors / calls,
155
+ avgMs: calls === 0 ? 0 : Math.round(totalMs / calls),
156
+ maxMs: slowest?.ms ?? 0,
157
+ slowestTool: slowest?.tool ?? "",
158
+ estTokensOut,
159
+ estTokensSaved,
160
+ savePct: estTokensOut + estTokensSaved === 0 ? 0 : estTokensSaved / (estTokensOut + estTokensSaved),
161
+ replayed: entries.filter((e) => e.replayed).length,
162
+ pending: entries.filter((e) => e.pending).length,
163
+ uncertain: entries.filter((e) => e.uncertain).length,
164
+ folds: entries.filter((e) => (e.folded_chars ?? 0) > 0).length,
165
+ topTools,
166
+ };
167
+ }
168
+ /** 过滤出明细(汇总始终基于全部保留日志,明细才吃 filter) */
169
+ export function filterUsage(entries, filter) {
170
+ let list = entries;
171
+ if (filter.tool)
172
+ list = list.filter((e) => e.tool === filter.tool);
173
+ if (filter.ok !== undefined)
174
+ list = list.filter((e) => e.ok === filter.ok);
175
+ if (filter.since)
176
+ list = list.filter((e) => e.ts >= filter.since);
177
+ const last = filter.last ?? 20;
178
+ return list.slice(-last).reverse();
179
+ }
180
+ export function filterRuntime(entries, filter) {
181
+ let list = entries;
182
+ if (filter.level)
183
+ list = list.filter((e) => e.level === filter.level);
184
+ if (filter.event)
185
+ list = list.filter((e) => e.event === filter.event);
186
+ if (filter.since)
187
+ list = list.filter((e) => e.ts >= filter.since);
188
+ const last = filter.last ?? 20;
189
+ return list.slice(-last).reverse();
190
+ }
191
+ /* ------------------------------ 渲染(供工具层) ------------------------------ */
192
+ function fmtK(n) {
193
+ return n >= 10000 ? `${(n / 1000).toFixed(0)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
194
+ }
195
+ function fmtPct(rate) {
196
+ return `${(rate * 100).toFixed(rate < 0.1 && rate > 0 ? 1 : 0)}%`;
197
+ }
198
+ function outcomeMark(e) {
199
+ if (!e.ok)
200
+ return "❌";
201
+ if (e.uncertain)
202
+ return "⚠️";
203
+ if (e.replayed)
204
+ return "↩️";
205
+ if (e.pending)
206
+ return "⏳";
207
+ return "✅";
208
+ }
209
+ export function renderUsageLogLines(root, filter) {
210
+ const entries = loadUsageEntries(root);
211
+ if (entries.length === 0) {
212
+ return [
213
+ "(使用日志为空:还没有记录到工具调用;日志在项目初始化后自动开始记录。)",
214
+ "提示: 本日志只记工具名与计量(耗时/输出量),不记录参数内容。",
215
+ ];
216
+ }
217
+ const summary = usageSummary(entries);
218
+ const list = filterUsage(entries, filter);
219
+ const conditions = [
220
+ filter.tool ? `工具=${filter.tool}` : "",
221
+ filter.ok !== undefined ? `结果=${filter.ok ? "成功" : "失败"}` : "",
222
+ filter.since ? `since=${filter.since}` : "",
223
+ ].filter(Boolean).join(" · ");
224
+ const L = [];
225
+ L.push(`## 使用日志汇总(保留 ${entries.length} 次调用)`);
226
+ L.push(`- 调用 ${summary.calls} 次 · 失败 ${summary.errors} 次(${fmtPct(summary.errorRate)})· 平均 ${summary.avgMs}ms · 最慢 ${summary.maxMs}ms(${summary.slowestTool})`);
227
+ L.push(`- 幂等复用 ${summary.replayed} 次 · 等待占位 ${summary.pending} 次 · 不确定 ${summary.uncertain} 次 · 发生折叠 ${summary.folds} 次`);
228
+ L.push(`- 估算输出 tokens ≈ ${fmtK(summary.estTokensOut)} · 折叠省下 ≈ ${fmtK(summary.estTokensSaved)}(占总产出 ${fmtPct(summary.savePct)})——省 token 主要来自输出折叠与过滤引导`);
229
+ if (summary.topTools.length > 0) {
230
+ L.push(`- 热门工具: ${summary.topTools.map((t) => `${t.tool}×${t.calls}${t.errors > 0 ? `(${t.errors}败)` : ""}`).join(" · ")}`);
231
+ }
232
+ L.push("");
233
+ L.push(`## 明细(最近 ${list.length} 条${conditions ? `,过滤: ${conditions}` : ""})`);
234
+ if (list.length === 0)
235
+ L.push("(无匹配条目,放宽过滤条件试试。)");
236
+ for (const e of list) {
237
+ const parts = [
238
+ e.ts.slice(0, 19),
239
+ `${outcomeMark(e)} ${e.tool}`,
240
+ `${e.ms}ms`,
241
+ e.out_chars !== undefined ? `输出 ${fmtK(e.out_chars)} 字/tok≈${fmtK(e.est_tokens ?? 0)}` : "",
242
+ (e.folded_tokens ?? 0) > 0 ? `折叠省 tok≈${fmtK(e.folded_tokens)}` : "",
243
+ e.error ? `错误: ${e.error.slice(0, 160)}` : "",
244
+ ].filter(Boolean);
245
+ L.push(parts.join(" "));
246
+ }
247
+ return L;
248
+ }
249
+ export function renderRuntimeLogLines(root, filter) {
250
+ const entries = loadRuntimeEntries(root);
251
+ if (entries.length === 0) {
252
+ return ["(运行日志为空:服务级事件(启动、watcher、工具报错)会在项目初始化后自动记录。)"];
253
+ }
254
+ const counts = { info: 0, warn: 0, error: 0 };
255
+ for (const e of entries)
256
+ counts[e.level] += 1;
257
+ const list = filterRuntime(entries, filter);
258
+ const conditions = [
259
+ filter.level ? `level=${filter.level}` : "",
260
+ filter.event ? `event=${filter.event}` : "",
261
+ filter.since ? `since=${filter.since}` : "",
262
+ ].filter(Boolean).join(" · ");
263
+ const L = [];
264
+ L.push(`## 运行日志(保留 ${entries.length} 条:info ${counts.info} · warn ${counts.warn} · error ${counts.error})`);
265
+ if (counts.error > 0) {
266
+ const lastError = [...entries].reverse().find((e) => e.level === "error");
267
+ if (lastError)
268
+ L.push(`- 最近 error: ${lastError.ts.slice(0, 19)} ${lastError.event} ${lastError.detail ?? ""}`.trimEnd());
269
+ }
270
+ L.push("");
271
+ L.push(`## 明细(最近 ${list.length} 条${conditions ? `,过滤: ${conditions}` : ""})`);
272
+ if (list.length === 0)
273
+ L.push("(无匹配条目,放宽过滤条件试试。)");
274
+ for (const e of list) {
275
+ L.push([e.ts.slice(0, 19), `[${e.level}]`, e.event, e.detail ?? ""].filter(Boolean).join(" ").trimEnd());
276
+ }
277
+ return L;
278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckychen1993/pm-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "AI 编码项目的单一事实来源 + 健康台账 MCP 服务(事实来源 / 路线图 / 防幻觉 / 七本账 / 断点续做 / token 经济)",
5
5
  "keywords": [
6
6
  "mcp",