@luckychen1993/pm-mcp 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -25,48 +25,63 @@ AI 编码项目的典型失控(本工具逐一给出机制):
25
25
  | 调试过程随会话消失 | `log_debug` 调试知识账(症状/根因/修法/验证) |
26
26
  | 长任务崩溃、上下文丢失 | 任务步骤清单 + `checkpoint` 断点存档,新会话从恢复点续做 |
27
27
  | 团队协作与知识传递 | Git 项目中共享 `.pm/` + 会话署名 + `onboard` 入职简报工作流 |
28
+ | 多 Agent 重复读写 | 并行读请求跨进程合并;写工具支持业务幂等键并自动抑制瞬时完全重复调用 |
28
29
  | token 猛烧 | 三段式定位 + 读类工具输出硬预算 + 文件用途索引 |
29
30
  | 法律知识产权 | `audit_license` 依赖许可证/copyleft 冲突 + 来源登记(provenance) |
30
31
  | 测试投机(删测试、skip 凑绿) | 禁用/蒸发/空测试检测 + 功能测试背书占比 + done 必附验证 |
31
32
 
32
- ## 一键添加到 AI 编程助手
33
+ ## 一个命令自动添加到 AI 编程助手
33
34
 
34
- 要求:Node.js ≥ 22.18。下面的配置固定到 npm 版本 `0.1.1`;首次启动由 `npx` 下载并在本机运行,pm-mcp 本身不需要 API Key,也不会调用远程模型。
35
+ 要求:Node.js ≥ 22.18。Windows、macOS 和 Linux 使用同一条命令:
35
36
 
36
- ### Codex / ChatGPT 桌面版 / Codex IDE 扩展
37
+ ```bash
38
+ npx -y @luckychen1993/pm-mcp@latest setup
39
+ ```
40
+
41
+ 安装器自动检测本机的 Codex、Claude Code、ZCode、Cursor 和 VS Code,并配置所有检测到的客户端。JSON 配置会先备份;已经存在的 Codex/Claude 配置默认保留,用 `setup --force` 才替换。先预览、不写配置:
42
+
43
+ ```bash
44
+ npx -y @luckychen1993/pm-mcp@latest setup --dry-run
45
+ ```
46
+
47
+ 非标准安装路径无法自动发现时,可在同一命令末尾指定 `--client codex|claude|zcode|cursor|vscode`;`--client print` 输出通用 MCP JSON。首次启动由 `npx` 下载并在本机运行,pm-mcp 本身不需要 API Key,也不会调用远程模型。
48
+
49
+ ### 客户端官方命令(自动检测失败时备用)
50
+
51
+ #### Codex / ChatGPT 桌面版 / Codex IDE 扩展
37
52
 
38
53
  Codex 的本地客户端共享同一份 MCP 配置。复制执行:
39
54
 
40
55
  ```bash
41
- codex mcp add pm-mcp -- npx -y @luckychen1993/pm-mcp@0.1.1
56
+ codex mcp add pm-mcp -- npx -y @luckychen1993/pm-mcp@0.1.3
42
57
  ```
43
58
 
44
59
  重启客户端后可用 `codex mcp list` 检查。该命令遵循 [OpenAI 官方 MCP CLI 格式](https://learn.chatgpt.com/docs/extend/mcp?surface=cli)。
45
60
 
46
- ### Claude Code
61
+ #### Claude Code
47
62
 
48
63
  ```bash
49
- claude mcp add pm-mcp --scope user -- npx -y @luckychen1993/pm-mcp@0.1.1
64
+ claude mcp add pm-mcp --scope user -- npx -y @luckychen1993/pm-mcp@0.1.3
50
65
  ```
51
66
 
52
- ### ZCode、Cursor、VS Code(Windows 一键脚本)
67
+ #### ZCode、Cursor、VS Code(Windows 备用脚本)
53
68
 
54
69
  将 `<client>` 替换为 `zcode`、`cursor` 或 `vscode`,整行复制到 PowerShell:
55
70
 
56
71
  ```powershell
57
- $u='https://raw.githubusercontent.com/happy520ai/pm-mcp/v0.1.1/install.ps1'; $f=Join-Path $env:TEMP 'install-pm-mcp.ps1'; Invoke-WebRequest $u -OutFile $f; & $f -Client <client>
72
+ $u='https://raw.githubusercontent.com/happy520ai/pm-mcp/v0.1.3/install.ps1'; $f=Join-Path $env:TEMP 'install-pm-mcp.ps1'; Invoke-WebRequest $u -OutFile $f; & $f -Client <client>
58
73
  ```
59
74
 
60
75
  脚本只写对应客户端的 MCP 配置;已有 JSON 配置会先生成带时间戳的备份。建议执行前先打开 [`install.ps1`](install.ps1) 审阅。ZCode 默认写入 `$ZCODE_HOME/cli/config.json` 或 `~/.zcode/cli/config.json`,Cursor 默认写入 `~/.cursor/mcp.json`;也可用 `-ConfigPath` 指定路径。
61
76
 
62
- ### 其他支持 stdio MCP 的客户端
77
+ #### 其他支持 stdio MCP 的客户端
63
78
 
64
79
  ```json
65
80
  {
66
81
  "mcpServers": {
67
82
  "pm-mcp": {
68
83
  "command": "npx",
69
- "args": ["-y", "@luckychen1993/pm-mcp@0.1.1"],
84
+ "args": ["-y", "@luckychen1993/pm-mcp@0.1.3"],
70
85
  "env": {}
71
86
  }
72
87
  }
@@ -139,6 +154,8 @@ snapshot_codebase + audit_structure 对账;audit_security 安全体检;audit
139
154
 
140
155
  Resources:`pm://dashboard`、`pm://roadmap`、`pm://tasks`、`pm://changelog`、`pm://architecture`、`pm://portfolio`、`pm://acceptance`。
141
156
 
157
+ 所有写工具都接受可选 `idempotency_key`。同一业务的多个 Agent 应传同一个稳定键(例如 `T-123:add-login-task`):同键同参数只执行一次并复用首次结果,同键不同参数直接拒绝。即使没有显式键,完全相同的并发写也会在短窗口内自动合并;完全相同的并行读会等待首个执行者并复用结果。
158
+
142
159
  ## 数据模型(被管理项目内的 `.pm/`;JSON/Markdown 账本可 git diff,`index.db` 是忽略并可重建的缓存)
143
160
 
144
161
  ```
@@ -161,7 +178,7 @@ PROJECT.md # 仓库根,自动生成的仪表盘(勿手改)
161
178
  changelog.md # 自动生成
162
179
  ```
163
180
 
164
- 全局注册表:`~/.pm-mcp/registry.json`。
181
+ 全局注册表:`~/.pm-mcp/registry.json`。跨进程读合并/写幂等运行态位于 `~/.pm-mcp/idempotency/<项目路径哈希>/`,不会污染被管理项目或进入 Git。
165
182
 
166
183
  ## 跨文件、跨模块、跨语言治理
167
184
 
@@ -238,6 +255,7 @@ npm run benchmark:volume -- --size-gib 20 --file-bytes 1048576 --full-audit --te
238
255
  - **许可证审计**覆盖直接+传递 Node 依赖(npm 嵌套与 pnpm 布局);语言单元虽可跨生态发现,但许可证深度仍不是所有生态的通用供应链图,也不替代法务意见。
239
256
  - **测试背书占比**是代理指标(功能是否挂了真实存在的测试文件),不是行覆盖率。
240
257
  - **token 消耗**本身是客户端的账,工具用「足迹/产出比」做空转代理指标。
258
+ - 幂等层能避免重复计算、重复落账并缩短重复调用输出,但无法返还模型在发起 MCP 调用前已经消耗的推理 token;客户端/Agent 编排仍应避免无意义扇出。
241
259
  - 结构/安全扫描忽略内置目录(node_modules/.git/dist/build/venv 等)与常见二进制/锁文件,尚不通用解析 `.gitignore`;`search_code` 的 rg 后端会解析。目录深度上限 64 层(超限计数上报,不静默)。
242
260
  - **仓库提供 fail-closed 巡检命令**;定时器、托管 CI 与会话 hooks 属部署层,必须在相应环境单独配置和监控。
243
261
  - 语义层面无法强制的(笔记内容是否属实、AI 是否真跑了测试)依赖 git 对账与审计交叉验证,不承诺语义级防伪。
@@ -7,7 +7,7 @@ import { acceptanceBaselineFingerprint, evaluateAcceptance } from "./acceptance-
7
7
  import { writeAcceptanceReport } from "./acceptance-report.js";
8
8
  import { requireInitialized } from "./paths.js";
9
9
  import { loadJson, saveJson, withLedgerLock } from "./store.js";
10
- import { tool } from "./tool-base.js";
10
+ import { toolI, toolR } from "./tool-base.js";
11
11
  import { refreshDerived } from "./dashboard.js";
12
12
  const IdSchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
13
13
  const SemVerSchema = z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
@@ -269,11 +269,11 @@ export function evaluateAcceptanceFile(root, input) {
269
269
  return { report, ...files };
270
270
  }
271
271
  export function registerAcceptanceTools(server, root) {
272
- tool(server, "list_acceptance_baselines", "列出版本化验收基线、审批状态、当前 fingerprint 和规模。", {}, () => {
272
+ toolR(server, root, "list_acceptance_baselines", "列出版本化验收基线、审批状态、当前 fingerprint 和规模。", {}, () => {
273
273
  requireInitialized(root);
274
274
  return JSON.stringify(listAcceptanceBaselines(root), null, 2);
275
275
  });
276
- tool(server, "get_acceptance_baseline", "读取一个验收基线;默认返回摘要,full=true 返回严格 JSON。", {
276
+ toolR(server, root, "get_acceptance_baseline", "读取一个验收基线;默认返回摘要,full=true 返回严格 JSON。", {
277
277
  baseline_id: IdSchema,
278
278
  baseline_version: SemVerSchema,
279
279
  full: z.boolean().optional(),
@@ -284,14 +284,14 @@ export function registerAcceptanceTools(server, root) {
284
284
  return JSON.stringify(baseline, null, 2);
285
285
  return JSON.stringify(listAcceptanceBaselines(root).find((item) => item.id === args.baseline_id && item.version === args.baseline_version), null, 2);
286
286
  });
287
- tool(server, "save_acceptance_baseline_draft", "从项目内 .pm/acceptance 下的严格 JSON 保存 draft 基线;批准后的同版本不可覆盖。", {
287
+ toolI(server, root, "save_acceptance_baseline_draft", "从项目内 .pm/acceptance 下的严格 JSON 保存 draft 基线;批准后的同版本不可覆盖。", {
288
288
  baseline_file: JsonPathSchema,
289
289
  }, (args) => {
290
290
  requireInitialized(root);
291
291
  const baseline = saveDraftAcceptanceBaselineFile(root, args.baseline_file);
292
292
  return `✅ draft ${baseline.baseline_id}@${baseline.baseline_version} 已保存;fingerprint=${acceptanceBaselineFingerprint(baseline)}`;
293
293
  });
294
- tool(server, "approve_acceptance_baseline", "按当前 SHA-256 fingerprint 原子批准基线;fingerprint 变化即拒绝,批准后修改必须升版。", {
294
+ toolI(server, root, "approve_acceptance_baseline", "按当前 SHA-256 fingerprint 原子批准基线;fingerprint 变化即拒绝,批准后修改必须升版。", {
295
295
  baseline_id: IdSchema,
296
296
  baseline_version: SemVerSchema,
297
297
  expected_fingerprint_sha256: Sha256Schema,
@@ -303,7 +303,7 @@ export function registerAcceptanceTools(server, root) {
303
303
  const approved = approveAcceptanceBaseline(root, args);
304
304
  return `✅ baseline ${approved.baseline_id}@${approved.baseline_version} 已批准;approved fingerprint=${acceptanceBaselineFingerprint(approved)}`;
305
305
  });
306
- tool(server, "evaluate_acceptance", "读取项目内 .pm/acceptance evaluation JSON,逐项复算证据 SHA-256,再生成不可手填、不可覆盖的正式报告。", {
306
+ toolI(server, root, "evaluate_acceptance", "读取项目内 .pm/acceptance evaluation JSON,逐项复算证据 SHA-256,再生成不可手填、不可覆盖的正式报告。", {
307
307
  baseline_id: IdSchema,
308
308
  baseline_version: SemVerSchema,
309
309
  evaluation_file: JsonPathSchema,
@@ -6,7 +6,7 @@ import { auditLicense } from "./license.js";
6
6
  import { isInitialized, requireInitialized } from "./paths.js";
7
7
  import { listRegistry } from "./registry.js";
8
8
  import { auditSecurity, listFindings, resolveFinding } from "./security.js";
9
- import { budgetLines, tool, toolW } from "./tool-base.js";
9
+ import { budgetLines, toolR, toolW } from "./tool-base.js";
10
10
  export function registerAuditTools(server, root) {
11
11
  toolW(server, root, "snapshot_codebase", "给代码结构拍快照(文件数/行数/目录分布/测试数/skip 标记/依赖清单)。之后 audit_structure 会与上次快照 diff。建议每个会话或每个里程碑结束时拍一次。", {}, () => {
12
12
  requireInitialized(root);
@@ -14,7 +14,7 @@ export function registerAuditTools(server, root) {
14
14
  refreshDerived(root);
15
15
  return [...summary, `下次 audit_structure 将以此为基线(${snapshot.file})。`].join("\n");
16
16
  });
17
- tool(server, "audit_structure", "八项结构对账(定期做):①增长与新增依赖 ②漂移对账(功能↔文件,防幻觉)③债务与重构配额 ④churn 热点 ⑤复杂度预算 ⑥索引覆盖率 ⑦足迹/产出 ⑧测试健康(禁用/蒸发/空测试/背书占比)。", {}, () => {
17
+ toolR(server, root, "audit_structure", "八项结构对账(定期做):①增长与新增依赖 ②漂移对账(功能↔文件,防幻觉)③债务与重构配额 ④churn 热点 ⑤复杂度预算 ⑥索引覆盖率 ⑦足迹/产出 ⑧测试健康(禁用/蒸发/空测试/背书占比)。", {}, () => {
18
18
  requireInitialized(root);
19
19
  return auditStructure(root, budgetLines(root));
20
20
  });
@@ -24,7 +24,7 @@ export function registerAuditTools(server, root) {
24
24
  refreshDerived(root);
25
25
  return report.text.join("\n");
26
26
  });
27
- tool(server, "list_findings", "查看安全台账(可按状态过滤)。", { status: z.enum(["open", "fixed", "accepted"]).optional() }, (args) => {
27
+ toolR(server, root, "list_findings", "查看安全台账(可按状态过滤)。", { status: z.enum(["open", "fixed", "accepted"]).optional() }, (args) => {
28
28
  requireInitialized(root);
29
29
  const list = listFindings(root, args.status);
30
30
  if (list.length === 0)
@@ -42,13 +42,13 @@ export function registerAuditTools(server, root) {
42
42
  refreshDerived(root);
43
43
  return `✅ ${f.id} 已置为 ${f.status}${f.note ? `(${f.note})` : ""}。`;
44
44
  });
45
- tool(server, "audit_license", "许可证审计(法律账):依赖许可证清单与 copyleft 冲突、源码中的 GPL 家族许可证头、LICENSE 文件检查、来源登记清单。离线启发式,不替代法务。", {}, () => {
45
+ toolR(server, root, "audit_license", "许可证审计(法律账):依赖许可证清单与 copyleft 冲突、源码中的 GPL 家族许可证头、LICENSE 文件检查、来源登记清单。离线启发式,不替代法务。", {}, () => {
46
46
  requireInitialized(root);
47
47
  return auditLicense(root, budgetLines(root));
48
48
  });
49
49
  }
50
- export function registerRegistryTools(server) {
51
- tool(server, "list_projects", "列出本机所有被 pm-mcp 管理的项目(全局注册表)。", {}, () => {
50
+ export function registerRegistryTools(server, root) {
51
+ toolR(server, root, "list_projects", "列出本机所有被 pm-mcp 管理的项目(全局注册表)。", {}, () => {
52
52
  const list = listRegistry();
53
53
  if (list.length === 0)
54
54
  return "(注册表为空。在任何项目里 init_project 后会自动登记。)";
package/dist/cli.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ if (process.argv[2] === "setup") {
3
+ try {
4
+ const { runSetup } = await import("./setup.js");
5
+ process.exitCode = runSetup(process.argv.slice(3));
6
+ }
7
+ catch (error) {
8
+ console.error(`[pm-mcp setup] ERROR: ${error instanceof Error ? error.message : String(error)}`);
9
+ process.exitCode = 1;
10
+ }
11
+ }
12
+ else {
13
+ await import("./index.js");
14
+ }
15
+ export {};
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { z } from "zod";
3
- import { budgetLines, tool, toolW } from "./tool-base.js";
3
+ import { budgetLines, toolI, toolR, toolW } from "./tool-base.js";
4
4
  import { foldLines } from "./budget.js";
5
5
  import { refreshDerived } from "./dashboard.js";
6
6
  import { requireInitialized } from "./paths.js";
@@ -64,13 +64,13 @@ function qualityLines(commands) {
64
64
  return commands.map((command, index) => `${index + 1}. [${command.kind}] (${command.cwd}) ${command.command} ${command.args.join(" ")}`);
65
65
  }
66
66
  export function registerGovernanceTools(server, root) {
67
- tool(server, "init_governance", "为旧项目初始化 .pm/governance.json;新项目 init_project 已自动创建。", {}, () => {
67
+ toolI(server, root, "init_governance", "为旧项目初始化 .pm/governance.json;新项目 init_project 已自动创建。", {}, () => {
68
68
  requireInitialized(root);
69
69
  const governance = initGovernance(root, {});
70
70
  refreshDerived(root);
71
71
  return `✅ 治理模型已初始化(schema v${governance.schema_version})。下一步用 upsert_module 声明模块、owner 与语言。`;
72
72
  });
73
- tool(server, "get_governance", "查看结构化模块、owner、公开接口、仓库依赖与强制策略。", {}, () => {
73
+ toolR(server, root, "get_governance", "查看结构化模块、owner、公开接口、仓库依赖与强制策略。", {}, () => {
74
74
  requireInitialized(root);
75
75
  return compactGovernance(loadGovernance(root));
76
76
  });
@@ -140,7 +140,7 @@ export function registerGovernanceTools(server, root) {
140
140
  refreshDerived(root);
141
141
  return `✅ 治理策略已更新:coverage>=${governance.policies.minimum_coverage_pct}% · semantic>=${governance.policies.minimum_semantic_assurance} · regex-fallback=${governance.policies.fail_on_semantic_fallback ? "forbidden" : "allowed"}`;
142
142
  });
143
- tool(server, "discover_languages", "递归发现 Node/Python/Go/Rust/JVM/.NET 单元及其受控质量命令,不执行命令。", {}, () => {
143
+ toolR(server, root, "discover_languages", "递归发现 Node/Python/Go/Rust/JVM/.NET 单元及其受控质量命令,不执行命令。", {}, () => {
144
144
  requireInitialized(root);
145
145
  const units = discoverProjectUnits(root);
146
146
  const coverage = assessQualityCoverage(units);
@@ -151,15 +151,15 @@ export function registerGovernanceTools(server, root) {
151
151
  lines.push(`- 🚩 无质量命令: ${coverage.withoutCommands.join(", ")}`);
152
152
  return foldLines(lines, { maxLines: budgetLines(root), hint: "按模块拆分治理 root" });
153
153
  });
154
- tool(server, "audit_governance", "审计跨文件/模块/语言语义覆盖、owner、公开接口、依赖边界、循环、unresolved 与质量矩阵覆盖。", {}, () => {
154
+ toolR(server, root, "audit_governance", "审计跨文件/模块/语言语义覆盖、owner、公开接口、依赖边界、循环、unresolved 与质量矩阵覆盖。", {}, () => {
155
155
  requireInitialized(root);
156
156
  return auditGovernance(root, budgetLines(root)).report;
157
157
  });
158
- tool(server, "list_semantic_evidence", "列出已登记的语言原生 AST、编译器或运行时语义证据;损坏文档会使整次读取失败。", {}, () => {
158
+ toolR(server, root, "list_semantic_evidence", "列出已登记的语言原生 AST、编译器或运行时语义证据;损坏文档会使整次读取失败。", {}, () => {
159
159
  requireInitialized(root);
160
160
  return JSON.stringify(listSemanticEvidence(root), null, 2);
161
161
  });
162
- tool(server, "save_semantic_evidence", "保存一份绑定源码 SHA-256 的语言原生 AST/编译器/运行时证据;过期摘要在治理审计中严格失败。", {
162
+ toolI(server, root, "save_semantic_evidence", "保存一份绑定源码 SHA-256 的语言原生 AST/编译器/运行时证据;过期摘要在治理审计中严格失败。", {
163
163
  id: z.string().trim().min(1).max(128),
164
164
  document: SemanticEvidenceDocumentSchema,
165
165
  }, (args) => {
@@ -167,7 +167,7 @@ export function registerGovernanceTools(server, root) {
167
167
  const saved = saveSemanticEvidence(root, args.id, args.document);
168
168
  return `✅ semantic evidence ${args.id} 已保存:${saved.file} · ${saved.analyzer.id} · ${saved.analyzer.assurance} · ${saved.status}`;
169
169
  });
170
- tool(server, "impact_analysis", "从变更文件沿 file/module 反向依赖闭包计算受影响文件与模块。", {
170
+ toolR(server, root, "impact_analysis", "从变更文件沿 file/module 反向依赖闭包计算受影响文件与模块。", {
171
171
  files: z.array(z.string().min(1)).min(1).max(100),
172
172
  }, (args) => {
173
173
  requireInitialized(root);
@@ -180,7 +180,7 @@ export function registerGovernanceTools(server, root) {
180
180
  ...(impact.unknownChangedFiles.length ? [`⚠️ 未进入语义图: ${impact.unknownChangedFiles.join(", ")}`] : []),
181
181
  ], { maxLines: budgetLines(root), hint: "缩小 files 输入或按模块运行" });
182
182
  });
183
- tool(server, "plan_quality_matrix", "生成跨语言真实质量命令计划;只规划,不启动进程。", {
183
+ toolR(server, root, "plan_quality_matrix", "生成跨语言真实质量命令计划;只规划,不启动进程。", {
184
184
  unit: z.string().optional(),
185
185
  kinds: z.array(z.enum(["test", "build", "lint", "typecheck", "coverage", "security"])).optional(),
186
186
  }, (args) => {
@@ -192,7 +192,7 @@ export function registerGovernanceTools(server, root) {
192
192
  ...(plan.coverage.withoutCommands.length ? [`🚩 无命令单元: ${plan.coverage.withoutCommands.join(", ")}`] : []),
193
193
  ], { maxLines: budgetLines(root), hint: "用 unit/kinds 过滤;真实执行需 run_quality_matrix confirm_execute=true" });
194
194
  });
195
- tool(server, "run_quality_matrix", "显式执行跨语言质量矩阵。只运行受支持适配器生成的 argv,shell=false;缺工具/超时/非零均失败。", {
195
+ toolI(server, root, "run_quality_matrix", "显式执行跨语言质量矩阵。只运行受支持适配器生成的 argv,shell=false;缺工具/超时/非零均失败。", {
196
196
  confirm_execute: z.literal(true).describe("必须显式为 true;该操作会运行目标项目构建/测试命令"),
197
197
  unit: z.string().optional(),
198
198
  kinds: z.array(z.enum(["test", "build", "lint", "typecheck", "coverage", "security"])).optional(),
@@ -219,7 +219,7 @@ export function registerGovernanceTools(server, root) {
219
219
  lines.push(`- [${item.status}] ${item.command.kind} ${item.command.cwd} (${item.durationMs}ms${item.exitCode === null ? "" : `, exit ${item.exitCode}`})`);
220
220
  return foldLines(lines, { maxLines: budgetLines(root), hint: "查看 .pm/quality-runs 结构化摘要(不落原始输出)" });
221
221
  });
222
- tool(server, "get_portfolio", "聚合当前项目或全局注册项目的阶段、里程碑、债务、安全、语言、跨仓依赖/版本/cycle;加载失败不能假绿。", {
222
+ toolR(server, root, "get_portfolio", "聚合当前项目或全局注册项目的阶段、里程碑、债务、安全、语言、跨仓依赖/版本/cycle;加载失败不能假绿。", {
223
223
  current_only: z.boolean().optional(),
224
224
  }, (args) => {
225
225
  requireInitialized(root);
@@ -0,0 +1,293 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { z } from "zod";
5
+ import { atomicWrite, withLedgerLock } from "./store.js";
6
+ import { pmPath, registryFile } from "./paths.js";
7
+ export const IdempotencyKeySchema = z.string()
8
+ .trim()
9
+ .min(1)
10
+ .max(128)
11
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/, "仅允许 ASCII 字母、数字、点、下划线、冒号和连字符");
12
+ const OperationRecordSchema = z.object({
13
+ schema_version: z.literal(1),
14
+ key: z.string().min(1).max(256),
15
+ explicit: z.boolean(),
16
+ tool: z.string().min(1),
17
+ args_sha256: z.string().regex(/^[a-f0-9]{64}$/),
18
+ mode: z.enum(["read", "write"]),
19
+ status: z.enum(["pending", "completed"]),
20
+ owner_pid: z.number().int().positive(),
21
+ owner_token: z.string().uuid(),
22
+ created_at: z.string().datetime({ offset: true }),
23
+ updated_at: z.string().datetime({ offset: true }),
24
+ result: z.string().nullable(),
25
+ });
26
+ const AUTO_REPLAY_MS = 1_000;
27
+ const READ_WAIT_MS = 30_000;
28
+ const LOCK_STALE_MS = 10_000;
29
+ function sha256(value) {
30
+ return createHash("sha256").update(value).digest("hex");
31
+ }
32
+ function canonical(value) {
33
+ if (Array.isArray(value))
34
+ return value.map(canonical);
35
+ if (value && typeof value === "object") {
36
+ return Object.fromEntries(Object.entries(value)
37
+ .filter(([, item]) => item !== undefined)
38
+ .sort(([left], [right]) => left.localeCompare(right))
39
+ .map(([key, item]) => [key, canonical(item)]));
40
+ }
41
+ return value;
42
+ }
43
+ export function operationArgsHash(args) {
44
+ return sha256(JSON.stringify(canonical(args)));
45
+ }
46
+ export function splitIdempotencyArgs(args) {
47
+ if (!args || typeof args !== "object" || Array.isArray(args))
48
+ return { businessArgs: args };
49
+ const { idempotency_key: rawKey, ...business } = args;
50
+ const explicitKey = rawKey === undefined ? undefined : IdempotencyKeySchema.parse(rawKey);
51
+ return { businessArgs: business, ...(explicitKey ? { explicitKey } : {}) };
52
+ }
53
+ function processAlive(pid) {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ }
58
+ catch (error) {
59
+ return error.code === "EPERM";
60
+ }
61
+ }
62
+ function operationDirectory(root) {
63
+ return path.join(path.dirname(registryFile()), "idempotency", sha256(path.resolve(root)));
64
+ }
65
+ function revisionFile(root) {
66
+ return path.join(operationDirectory(root), "revision");
67
+ }
68
+ function readRevision(root) {
69
+ try {
70
+ return fs.readFileSync(revisionFile(root), "utf8").trim() || "0";
71
+ }
72
+ catch {
73
+ return "0";
74
+ }
75
+ }
76
+ function bumpRevision(root) {
77
+ fs.mkdirSync(operationDirectory(root), { recursive: true });
78
+ atomicWrite(revisionFile(root), randomUUID() + "\n");
79
+ }
80
+ function operationFile(root, key) {
81
+ return path.join(operationDirectory(root), `${sha256(key)}.json`);
82
+ }
83
+ function readRecord(file) {
84
+ if (!fs.existsSync(file))
85
+ return null;
86
+ let raw;
87
+ try {
88
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
89
+ }
90
+ catch (error) {
91
+ throw new Error(`幂等记录损坏,拒绝继续写入: ${file}: ${error instanceof Error ? error.message : String(error)}`);
92
+ }
93
+ const parsed = OperationRecordSchema.safeParse(raw);
94
+ if (!parsed.success)
95
+ throw new Error(`幂等记录 schema 无效,拒绝继续写入: ${file}`);
96
+ return parsed.data;
97
+ }
98
+ function lockOwner(file) {
99
+ try {
100
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
101
+ return typeof parsed.pid === "number" && typeof parsed.token === "string" ? { pid: parsed.pid, token: parsed.token } : null;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function withOperationLock(file, fn) {
108
+ const lock = `${file}.lock`;
109
+ const token = randomUUID();
110
+ const payload = JSON.stringify({ pid: process.pid, token });
111
+ let held = false;
112
+ for (let index = 0; index < 150 && !held; index += 1) {
113
+ try {
114
+ fs.writeFileSync(lock, payload, { flag: "wx" });
115
+ held = true;
116
+ }
117
+ catch {
118
+ try {
119
+ const current = lockOwner(lock);
120
+ const stale = Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS;
121
+ if (stale && (!current || !processAlive(current.pid)))
122
+ fs.rmSync(lock, { force: true });
123
+ }
124
+ catch {
125
+ // The lock disappeared between checks; retry.
126
+ }
127
+ if (!held)
128
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
129
+ }
130
+ }
131
+ if (!held)
132
+ throw new Error("幂等键锁获取超时(3s),请稍后重试同一 idempotency_key。");
133
+ try {
134
+ return fn();
135
+ }
136
+ finally {
137
+ try {
138
+ if (lockOwner(lock)?.token === token)
139
+ fs.rmSync(lock, { force: true });
140
+ }
141
+ catch {
142
+ // Best-effort release; stale-owner recovery handles process crashes.
143
+ }
144
+ }
145
+ }
146
+ function claim(root, tool, args, mode, explicitKey) {
147
+ const argsHash = operationArgsHash(args);
148
+ const key = explicitKey ?? `auto:${mode}:${tool}:${argsHash}`;
149
+ const file = operationFile(root, key);
150
+ fs.mkdirSync(path.dirname(file), { recursive: true });
151
+ return withOperationLock(file, () => {
152
+ const existing = readRecord(file);
153
+ if (existing) {
154
+ if (existing.key !== key || existing.tool !== tool || existing.mode !== mode || existing.args_sha256 !== argsHash) {
155
+ throw new Error(`idempotency_key 冲突:${key} 已绑定其他工具或参数,拒绝误复用。`);
156
+ }
157
+ if (existing.status === "completed") {
158
+ const age = Date.now() - Date.parse(existing.updated_at);
159
+ if (existing.explicit || (mode === "write" && age <= AUTO_REPLAY_MS))
160
+ return { kind: "replay", record: existing };
161
+ }
162
+ else if (processAlive(existing.owner_pid)) {
163
+ return { kind: "pending", file, record: existing };
164
+ }
165
+ }
166
+ const timestamp = new Date().toISOString();
167
+ const record = {
168
+ schema_version: 1,
169
+ key,
170
+ explicit: explicitKey !== undefined,
171
+ tool,
172
+ args_sha256: argsHash,
173
+ mode,
174
+ status: "pending",
175
+ owner_pid: process.pid,
176
+ owner_token: randomUUID(),
177
+ created_at: timestamp,
178
+ updated_at: timestamp,
179
+ result: null,
180
+ };
181
+ atomicWrite(file, JSON.stringify(OperationRecordSchema.parse(record), null, 2) + "\n");
182
+ return { kind: "execute", file, record };
183
+ });
184
+ }
185
+ function complete(file, reservation, result) {
186
+ withOperationLock(file, () => {
187
+ const current = readRecord(file);
188
+ if (!current || current.owner_token !== reservation.owner_token || current.status !== "pending") {
189
+ throw new Error(`幂等记录所有权变化,拒绝把结果写到其他操作: ${reservation.key}`);
190
+ }
191
+ current.status = "completed";
192
+ current.result = result;
193
+ current.updated_at = new Date().toISOString();
194
+ atomicWrite(file, JSON.stringify(OperationRecordSchema.parse(current), null, 2) + "\n");
195
+ });
196
+ }
197
+ function abandon(file, reservation) {
198
+ try {
199
+ withOperationLock(file, () => {
200
+ const current = readRecord(file);
201
+ if (current?.owner_token === reservation.owner_token && current.status === "pending")
202
+ fs.rmSync(file, { force: true });
203
+ });
204
+ }
205
+ catch {
206
+ // Preserve the original operation error. A dead-owner retry can recover later.
207
+ }
208
+ }
209
+ function replay(record) {
210
+ return {
211
+ text: record.result ?? `⏳ 相同业务正在由另一 Agent 执行:${record.key}`,
212
+ replayed: record.status === "completed",
213
+ pending: record.status === "pending",
214
+ key: record.key,
215
+ };
216
+ }
217
+ export function runIdempotentWriteSync(root, tool, args, fn) {
218
+ const { businessArgs, explicitKey } = splitIdempotencyArgs(args);
219
+ const reservation = claim(root, tool, businessArgs, "write", explicitKey);
220
+ if (reservation.kind !== "execute")
221
+ return replay(reservation.record);
222
+ try {
223
+ const text = withLedgerLock(root, () => {
224
+ const value = fn(businessArgs);
225
+ bumpRevision(root);
226
+ return value;
227
+ });
228
+ complete(reservation.file, reservation.record, text);
229
+ return { text, replayed: false, pending: false, key: reservation.record.key };
230
+ }
231
+ catch (error) {
232
+ abandon(reservation.file, reservation.record);
233
+ throw error;
234
+ }
235
+ }
236
+ export async function runIdempotentWrite(root, tool, args, fn) {
237
+ const { businessArgs, explicitKey } = splitIdempotencyArgs(args);
238
+ const reservation = claim(root, tool, businessArgs, "write", explicitKey);
239
+ if (reservation.kind !== "execute")
240
+ return replay(reservation.record);
241
+ try {
242
+ const text = await fn(businessArgs);
243
+ bumpRevision(root);
244
+ complete(reservation.file, reservation.record, text);
245
+ return { text, replayed: false, pending: false, key: reservation.record.key };
246
+ }
247
+ catch (error) {
248
+ abandon(reservation.file, reservation.record);
249
+ throw error;
250
+ }
251
+ }
252
+ async function waitForRead(file, record) {
253
+ const deadline = Date.now() + READ_WAIT_MS;
254
+ while (Date.now() < deadline) {
255
+ await new Promise((resolve) => setTimeout(resolve, 25));
256
+ const current = readRecord(file);
257
+ if (!current || current.owner_token !== record.owner_token)
258
+ return null;
259
+ if (current.status === "completed")
260
+ return current;
261
+ if (!processAlive(current.owner_pid))
262
+ return null;
263
+ }
264
+ return null;
265
+ }
266
+ export async function runCoalescedRead(root, tool, args, fn) {
267
+ // Read-only calls before project initialization must not create .pm state.
268
+ if (!fs.existsSync(pmPath(root, "project.json"))) {
269
+ const text = await fn(args);
270
+ return { text, replayed: false, pending: false, key: "uncached:uninitialized" };
271
+ }
272
+ const readClaim = () => claim(root, tool, { revision: readRevision(root), args }, "read");
273
+ let reservation = readClaim();
274
+ if (reservation.kind === "replay")
275
+ return replay(reservation.record);
276
+ if (reservation.kind === "pending") {
277
+ const completed = await waitForRead(reservation.file, reservation.record);
278
+ if (completed)
279
+ return replay(completed);
280
+ reservation = readClaim();
281
+ if (reservation.kind !== "execute")
282
+ return replay(reservation.record);
283
+ }
284
+ try {
285
+ const text = await fn(args);
286
+ complete(reservation.file, reservation.record, text);
287
+ return { text, replayed: false, pending: false, key: reservation.record.key };
288
+ }
289
+ catch (error) {
290
+ abandon(reservation.file, reservation.record);
291
+ throw error;
292
+ }
293
+ }
package/dist/index.js CHANGED
@@ -22,7 +22,13 @@ for (let i = 0; i < argv.length; i++) {
22
22
  explicitRoot = argv[i + 1];
23
23
  }
24
24
  const root = resolveRoot(explicitRoot);
25
- const server = new McpServer({ name: "pm-mcp", version: "0.1.1" });
25
+ const server = new McpServer({ name: "pm-mcp", version: "0.1.3" }, {
26
+ instructions: [
27
+ "多 Agent 规则:相同业务的所有写工具调用必须携带相同 idempotency_key(建议 task-id:operation);同键同参数只执行一次,同键不同参数会被拒绝。",
28
+ "完全相同的并行读请求会由服务端合并并复用结果;不要让多个分支重复请求同一工具和参数。",
29
+ "看到“幂等复用”时直接采用首次结果;看到“幂等占位/正在执行”时等待负责该业务的 Agent,不要换键重试。",
30
+ ].join(" "),
31
+ });
26
32
  registerAllTools(server, root);
27
33
  /* -------------------------------- Resources ------------------------------- */
28
34
  function safeText(fn) {
@@ -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
  });
@@ -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/setup.js ADDED
@@ -0,0 +1,257 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.3";
6
+ export const SERVER_NAME = "pm-mcp";
7
+ const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode"];
8
+ export function parseSetupArgs(argv) {
9
+ const options = { client: "auto", force: false, dryRun: false, help: false };
10
+ for (let index = 0; index < argv.length; index += 1) {
11
+ const arg = argv[index];
12
+ if (arg === "--client") {
13
+ const value = argv[index + 1];
14
+ if (!value || !["auto", "all", "codex", "claude", "zcode", "cursor", "vscode", "print"].includes(value)) {
15
+ throw new Error("--client must be auto, all, codex, claude, zcode, cursor, vscode, or print");
16
+ }
17
+ options.client = value;
18
+ index += 1;
19
+ }
20
+ else if (arg === "--force") {
21
+ options.force = true;
22
+ }
23
+ else if (arg === "--dry-run") {
24
+ options.dryRun = true;
25
+ }
26
+ else if (arg === "--help" || arg === "-h") {
27
+ options.help = true;
28
+ }
29
+ else {
30
+ throw new Error(`unknown setup argument: ${arg}`);
31
+ }
32
+ }
33
+ return options;
34
+ }
35
+ export function commandExists(command, env = process.env, platform = process.platform) {
36
+ const searchPath = env.PATH ?? env.Path ?? "";
37
+ const suffixes = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
38
+ return searchPath.split(path.delimiter).filter(Boolean).some((directory) => suffixes.some((suffix) => {
39
+ try {
40
+ return fs.statSync(path.join(directory, command + suffix)).isFile();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }));
46
+ }
47
+ export function detectClients(context) {
48
+ const found = [];
49
+ const { env, home, exists } = context;
50
+ if (context.commandExists("codex") || Boolean(env.CODEX_HOME) || exists(path.join(home, ".codex")))
51
+ found.push("codex");
52
+ if (context.commandExists("claude"))
53
+ found.push("claude");
54
+ if (context.commandExists("zcode") || Boolean(env.ZCODE_HOME) || exists(path.join(home, ".zcode")))
55
+ found.push("zcode");
56
+ if (context.commandExists("cursor") || exists(path.join(home, ".cursor")))
57
+ found.push("cursor");
58
+ if (context.commandExists("code"))
59
+ found.push("vscode");
60
+ return found;
61
+ }
62
+ function asObject(value, label) {
63
+ if (!value || typeof value !== "object" || Array.isArray(value))
64
+ throw new Error(`${label} must be a JSON object`);
65
+ return value;
66
+ }
67
+ export function mergeJsonServer(document, shape) {
68
+ const root = asObject(document, "configuration root");
69
+ let servers;
70
+ if (shape === "zcode") {
71
+ const mcp = root.mcp === undefined ? (root.mcp = {}) : root.mcp;
72
+ const mcpObject = asObject(mcp, "mcp");
73
+ const currentServers = mcpObject.servers === undefined ? (mcpObject.servers = {}) : mcpObject.servers;
74
+ servers = asObject(currentServers, "mcp.servers");
75
+ }
76
+ else {
77
+ const currentServers = root.mcpServers === undefined ? (root.mcpServers = {}) : root.mcpServers;
78
+ servers = asObject(currentServers, "mcpServers");
79
+ }
80
+ servers[SERVER_NAME] = { command: "npx", args: ["-y", PACKAGE_SPEC], env: {} };
81
+ return root;
82
+ }
83
+ function backupName(file) {
84
+ const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "");
85
+ return `${file}.backup-${stamp}-${process.pid}`;
86
+ }
87
+ export function writeJsonClientConfig(file, shape, dryRun = false) {
88
+ const absolute = path.resolve(file);
89
+ const exists = fs.existsSync(absolute);
90
+ let document = {};
91
+ if (exists) {
92
+ const stat = fs.lstatSync(absolute);
93
+ if (!stat.isFile() || stat.isSymbolicLink())
94
+ throw new Error(`refusing non-regular config file: ${absolute}`);
95
+ try {
96
+ document = JSON.parse(fs.readFileSync(absolute, "utf8"));
97
+ }
98
+ catch (error) {
99
+ throw new Error(`invalid JSON in ${absolute}; no changes made: ${error instanceof Error ? error.message : String(error)}`);
100
+ }
101
+ }
102
+ const merged = mergeJsonServer(document, shape);
103
+ if (dryRun)
104
+ return null;
105
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
106
+ const backup = exists ? backupName(absolute) : null;
107
+ if (backup)
108
+ fs.copyFileSync(absolute, backup, fs.constants.COPYFILE_EXCL);
109
+ const temporary = `${absolute}.tmp-${process.pid}`;
110
+ try {
111
+ fs.writeFileSync(temporary, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
112
+ fs.renameSync(temporary, absolute);
113
+ }
114
+ catch (error) {
115
+ try {
116
+ fs.unlinkSync(temporary);
117
+ }
118
+ catch { /* nothing to clean */ }
119
+ throw error;
120
+ }
121
+ return backup;
122
+ }
123
+ function codexConfigPath(env, home) {
124
+ return path.join(env.CODEX_HOME || path.join(home, ".codex"), "config.toml");
125
+ }
126
+ function zcodeConfigPath(env, home) {
127
+ return path.join(env.ZCODE_HOME || path.join(home, ".zcode"), "cli", "config.json");
128
+ }
129
+ function hasCodexServer(text) {
130
+ return /^\s*\[mcp_servers\.(?:pm-mcp|"pm-mcp")\]\s*$/m.test(text);
131
+ }
132
+ function appendCodexConfig(file, dryRun, log) {
133
+ const absolute = path.resolve(file);
134
+ const existing = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
135
+ if (hasCodexServer(existing)) {
136
+ log(`[skip] Codex already has ${SERVER_NAME}; use --force with the Codex CLI to replace it.`);
137
+ return;
138
+ }
139
+ log(`[${dryRun ? "plan" : "write"}] Codex: ${absolute}`);
140
+ if (dryRun)
141
+ return;
142
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
143
+ if (fs.existsSync(absolute))
144
+ fs.copyFileSync(absolute, backupName(absolute), fs.constants.COPYFILE_EXCL);
145
+ const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
146
+ const block = `${separator}\n[mcp_servers.${SERVER_NAME}]\ncommand = "npx"\nargs = ["-y", "${PACKAGE_SPEC}"]\n`;
147
+ fs.appendFileSync(absolute, block, "utf8");
148
+ }
149
+ function run(command, args, visible) {
150
+ const result = spawnSync(command, args, {
151
+ shell: false,
152
+ windowsHide: true,
153
+ stdio: visible ? "inherit" : "ignore",
154
+ });
155
+ if (result.error)
156
+ return -1;
157
+ return result.status ?? -1;
158
+ }
159
+ function configureCliClient(client, options, available, log) {
160
+ if (!available)
161
+ return false;
162
+ const getArgs = ["mcp", "get", SERVER_NAME];
163
+ const exists = run(client, getArgs, false) === 0;
164
+ if (exists && !options.force) {
165
+ log(`[skip] ${client} already has ${SERVER_NAME}; run setup --force to replace it.`);
166
+ return true;
167
+ }
168
+ const addArgs = client === "codex"
169
+ ? ["mcp", "add", SERVER_NAME, "--", "npx", "-y", PACKAGE_SPEC]
170
+ : ["mcp", "add", SERVER_NAME, "--scope", "user", "--", "npx", "-y", PACKAGE_SPEC];
171
+ log(`[${options.dryRun ? "plan" : "run"}] ${client} ${addArgs.join(" ")}`);
172
+ if (options.dryRun)
173
+ return true;
174
+ if (exists && run(client, ["mcp", "remove", SERVER_NAME], true) !== 0)
175
+ throw new Error(`${client} could not remove the existing server`);
176
+ if (run(client, addArgs, true) !== 0)
177
+ throw new Error(`${client} could not add ${SERVER_NAME}`);
178
+ return true;
179
+ }
180
+ function configureClient(client, options, context, log) {
181
+ if (client === "codex") {
182
+ if (configureCliClient("codex", options, context.commandExists("codex"), log))
183
+ return;
184
+ appendCodexConfig(codexConfigPath(context.env, context.home), options.dryRun, log);
185
+ return;
186
+ }
187
+ if (client === "claude") {
188
+ if (!configureCliClient("claude", options, context.commandExists("claude"), log))
189
+ throw new Error("claude was not found in PATH");
190
+ return;
191
+ }
192
+ if (client === "zcode" || client === "cursor") {
193
+ const file = client === "zcode" ? zcodeConfigPath(context.env, context.home) : path.join(context.home, ".cursor", "mcp.json");
194
+ log(`[${options.dryRun ? "plan" : "write"}] ${client}: ${file}`);
195
+ const backup = writeJsonClientConfig(file, client === "zcode" ? "zcode" : "standard", options.dryRun);
196
+ if (backup)
197
+ log(`[backup] ${backup}`);
198
+ return;
199
+ }
200
+ if (!context.commandExists("code"))
201
+ throw new Error("code was not found in PATH");
202
+ const definition = JSON.stringify({ name: SERVER_NAME, command: "npx", args: ["-y", PACKAGE_SPEC] });
203
+ log(`[${options.dryRun ? "plan" : "run"}] code --add-mcp ${definition}`);
204
+ if (!options.dryRun && run("code", ["--add-mcp", definition], true) !== 0)
205
+ throw new Error("VS Code could not add pm-mcp");
206
+ }
207
+ function printHelp(log) {
208
+ log("pm-mcp setup — configure local AI coding clients");
209
+ log("");
210
+ log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|print] [--force] [--dry-run]`);
211
+ log("Default auto mode configures every detected supported client.");
212
+ }
213
+ export function runSetup(argv, overrides = {}) {
214
+ const options = parseSetupArgs(argv);
215
+ const log = overrides.log ?? console.log;
216
+ if (options.help) {
217
+ printHelp(log);
218
+ return 0;
219
+ }
220
+ if (options.client === "print") {
221
+ log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { command: "npx", args: ["-y", PACKAGE_SPEC], env: {} } } }, null, 2));
222
+ return 0;
223
+ }
224
+ const env = overrides.env ?? process.env;
225
+ const home = overrides.home ?? os.homedir();
226
+ const context = {
227
+ env,
228
+ home,
229
+ commandExists: overrides.commandExists ?? ((command) => commandExists(command, env)),
230
+ exists: overrides.exists ?? fs.existsSync,
231
+ };
232
+ const selected = options.client === "all"
233
+ ? CLIENTS
234
+ : options.client === "auto"
235
+ ? detectClients(context)
236
+ : [options.client];
237
+ if (selected.length === 0) {
238
+ log("No supported client was detected. Use --client <name>, or --client print for generic JSON.");
239
+ return 1;
240
+ }
241
+ log(`pm-mcp setup ${PACKAGE_SPEC}`);
242
+ log(`clients: ${selected.join(", ")}`);
243
+ let failures = 0;
244
+ for (const client of selected) {
245
+ try {
246
+ configureClient(client, options, context, log);
247
+ }
248
+ catch (error) {
249
+ failures += 1;
250
+ console.error(`[failed] ${client}: ${error instanceof Error ? error.message : String(error)}`);
251
+ }
252
+ }
253
+ if (failures > 0)
254
+ return 1;
255
+ log("Done. Restart the configured client and ask it to call pm-mcp get_status.");
256
+ return 0;
257
+ }
package/dist/store.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { DebugLogFileSchema, FeaturesFileSchema, FileNotesFileSchema, now, ProjectSchema, RoadmapFileSchema, SecurityFileSchema, SessionsFileSchema, TasksFileSchema, } from "./types.js";
4
5
  import { DEBUGLOG_JSON, EXTRA_RULES_FILE, FEATURES_JSON, FILE_NOTES_JSON, PROJECT_JSON, ROADMAP_JSON, SECURITY_JSON, SESSIONS_JSON, TASKS_JSON, pmPath, } from "./paths.js";
5
6
  import { ExtraRulesFileSchema } from "./types.js";
@@ -39,24 +40,55 @@ export function saveJson(file, data) {
39
40
  }
40
41
  /**
41
42
  * 账本互斥锁:把「读账本 → 改 → 写」整个事务串行化,多客户端(如 ZCode+Codex 并行)
42
- * 不再互相覆盖丢数据。锁文件 .pm/.lock(O_EXCL 创建);持有超过 10s 视为残留强制接管
43
- * (进程崩溃不留死锁);等待上限 3s,超时明确报错而非静默丢写。
43
+ * 不再互相覆盖丢数据。锁文件 .pm/.lock(O_EXCL 创建);超过 10s 且持锁进程已死亡
44
+ * 才视为残留并接管。活着的长任务不会被误抢锁;释放时也只删除自己的 token。
45
+ * 等待上限 3s,超时明确报错而非静默丢写。
44
46
  */
45
47
  export function withLedgerLock(root, fn) {
46
48
  const lockFile = pmPath(root, ".lock");
47
49
  fs.mkdirSync(pmPath(root), { recursive: true });
50
+ const token = randomUUID();
51
+ const payload = JSON.stringify({ pid: process.pid, token, created_at: new Date().toISOString() });
52
+ const owner = () => {
53
+ try {
54
+ const raw = fs.readFileSync(lockFile, "utf8");
55
+ try {
56
+ const parsed = JSON.parse(raw);
57
+ return typeof parsed.pid === "number"
58
+ ? { pid: parsed.pid, ...(typeof parsed.token === "string" ? { token: parsed.token } : {}) }
59
+ : null;
60
+ }
61
+ catch {
62
+ const pid = Number(raw.trim());
63
+ return Number.isInteger(pid) && pid > 0 ? { pid } : null;
64
+ }
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ };
70
+ const alive = (pid) => {
71
+ try {
72
+ process.kill(pid, 0);
73
+ return true;
74
+ }
75
+ catch (error) {
76
+ return error.code === "EPERM";
77
+ }
78
+ };
48
79
  const acquire = () => {
49
80
  try {
50
- fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
81
+ fs.writeFileSync(lockFile, payload, { flag: "wx" });
51
82
  return true;
52
83
  }
53
84
  catch {
54
85
  try {
55
86
  const st = fs.statSync(lockFile);
56
- if (Date.now() - st.mtimeMs > 10_000) {
87
+ const current = owner();
88
+ if (Date.now() - st.mtimeMs > 10_000 && (!current || !alive(current.pid))) {
57
89
  fs.rmSync(lockFile, { force: true });
58
90
  try {
59
- fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
91
+ fs.writeFileSync(lockFile, payload, { flag: "wx" });
60
92
  return true;
61
93
  }
62
94
  catch {
@@ -84,7 +116,8 @@ export function withLedgerLock(root, fn) {
84
116
  }
85
117
  finally {
86
118
  try {
87
- fs.rmSync(lockFile, { force: true });
119
+ if (owner()?.token === token)
120
+ fs.rmSync(lockFile, { force: true });
88
121
  }
89
122
  catch {
90
123
  /* 尽力释放 */
@@ -4,7 +4,7 @@ import { foldLines, normSep } from "./budget.js";
4
4
  import { requireInitialized } from "./paths.js";
5
5
  import { loadRoadmap, loadTasks, nextId, saveTasks } from "./store.js";
6
6
  import { now } from "./types.js";
7
- import { budgetLines, tool, toolW } from "./tool-base.js";
7
+ import { budgetLines, toolR, toolW } from "./tool-base.js";
8
8
  const stepSchema = z.object({ text: z.string(), done: z.boolean().optional() });
9
9
  export function registerTaskTools(server, root) {
10
10
  toolW(server, root, "add_task", "添加任务。长任务务必给 steps(显式步骤清单,断点续做的载体);走了捷径 type=debt 登记(债务账);修 bug type=fix。", {
@@ -55,7 +55,7 @@ export function registerTaskTools(server, root) {
55
55
  : "";
56
56
  return `✅ 任务 ${id}「${args.title}」已创建(${args.type ?? "feature"}/${args.milestone ?? "无里程碑"})。${tip}`;
57
57
  });
58
- tool(server, "list_tasks", "列出任务(默认不含 done/cancelled)。可按状态/类型/里程碑/标签过滤。", {
58
+ toolR(server, root, "list_tasks", "列出任务(默认不含 done/cancelled)。可按状态/类型/里程碑/标签过滤。", {
59
59
  status: z.enum(["backlog", "todo", "in_progress", "blocked", "done", "cancelled"]).optional(),
60
60
  type: z.enum(["feature", "refactor", "fix", "chore", "debt"]).optional(),
61
61
  milestone: z.string().optional(),
package/dist/tool-base.js CHANGED
@@ -1,21 +1,44 @@
1
- import { loadProject, withLedgerLock } from "./store.js";
2
- export function tool(server, name, description, schema, fn) {
3
- server.registerTool(name, { description, inputSchema: schema }, (async (args) => {
1
+ import { loadProject } from "./store.js";
2
+ import { IdempotencyKeySchema, runCoalescedRead, runIdempotentWrite, runIdempotentWriteSync } from "./idempotency.js";
3
+ /** 只读工具:跨 Agent/进程合并完全相同的并行请求,短窗复用结果。 */
4
+ export function toolR(server, root, name, description, schema, fn) {
5
+ server.registerTool(name, { description, inputSchema: schema, annotations: { readOnlyHint: true } }, (async (args) => {
4
6
  try {
5
- const text = await fn(args);
6
- return { content: [{ type: "text", text }] };
7
+ const result = await runCoalescedRead(root, name, args, fn);
8
+ return { content: [{ type: "text", text: result.text }] };
7
9
  }
8
10
  catch (error) {
9
11
  return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
10
12
  }
11
13
  }));
12
14
  }
13
- /** 写工具必须使用同步 handler,保证跨进程账本锁覆盖完整读-改-写。 */
15
+ const idempotencyDescription = "多 Agent 执行同一业务时传相同 idempotency_key;同键同参数只执行一次,同键不同参数拒绝。";
16
+ function writeSchema(schema) {
17
+ return {
18
+ ...schema,
19
+ idempotency_key: IdempotencyKeySchema.optional().describe("跨 Agent 业务幂等键,建议格式 task-id:operation,如 T-123:add-task"),
20
+ };
21
+ }
22
+ /** 同步写工具:业务幂等占位 + 跨进程账本锁覆盖完整读-改-写。 */
14
23
  export function toolW(server, root, name, description, schema, fn) {
15
- server.registerTool(name, { description, inputSchema: schema }, ((args) => {
24
+ server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, ((args) => {
25
+ try {
26
+ const result = runIdempotentWriteSync(root, name, args, fn);
27
+ const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
28
+ return { content: [{ type: "text", text: prefix + result.text }] };
29
+ }
30
+ catch (error) {
31
+ return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
32
+ }
33
+ }));
34
+ }
35
+ /** 已自带内部锁或异步执行的写工具:先跨进程占位,完成后缓存结果。 */
36
+ export function toolI(server, root, name, description, schema, fn) {
37
+ server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, (async (args) => {
16
38
  try {
17
- const text = withLedgerLock(root, () => fn(args));
18
- return { content: [{ type: "text", text }] };
39
+ const result = await runIdempotentWrite(root, name, args, fn);
40
+ const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
41
+ return { content: [{ type: "text", text: prefix + result.text }] };
19
42
  }
20
43
  catch (error) {
21
44
  return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
package/dist/tools.js CHANGED
@@ -15,7 +15,7 @@ export function registerAllTools(server, root) {
15
15
  registerSessionTools(server, root);
16
16
  registerSearchTools(server, root);
17
17
  registerAuditTools(server, root);
18
- registerRegistryTools(server);
18
+ registerRegistryTools(server, root);
19
19
  registerGovernanceTools(server, root);
20
20
  registerAcceptanceTools(server, root);
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckychen1993/pm-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "AI 编码项目的单一事实来源 + 健康台账 MCP 服务(事实来源 / 路线图 / 防幻觉 / 七本账 / 断点续做 / token 经济)",
5
5
  "keywords": [
6
6
  "mcp",
@@ -26,7 +26,7 @@
26
26
  "dist/"
27
27
  ],
28
28
  "bin": {
29
- "pm-mcp": "dist/index.js"
29
+ "pm-mcp": "dist/cli.js"
30
30
  },
31
31
  "main": "dist/index.js",
32
32
  "engines": {