@luckychen1993/pm-mcp 0.4.0 → 0.6.0

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.
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { dashboardFile, pmPath } from "./paths.js";
5
+ import { loadRoadmap, loadTasks } from "./store.js";
6
+ import { renderRoadmap } from "./roadmap.js";
7
+ import { buildChangelog } from "./dashboard.js";
8
+ import { auditGovernance } from "./governance-audit.js";
9
+ import { buildPortfolioReport, loadPortfolioProject } from "./portfolio.js";
10
+ import { listAcceptanceBaselines } from "./acceptance-tools.js";
11
+ import { AcceptanceReportSchema } from "./acceptance-report.js";
12
+ import { runCoalescedRead } from "./idempotency.js";
13
+ import { budgetLines, recordUsage, requestContext } from "./tool-base.js";
14
+ import { taskPage } from "./task-pagination.js";
15
+ export function registerResources(server, root) {
16
+ function safeText(fn) {
17
+ try {
18
+ return fn();
19
+ }
20
+ catch (e) {
21
+ return `(不可用: ${e.message})`;
22
+ }
23
+ }
24
+ async function resourceText(name, fn) {
25
+ const started = Date.now();
26
+ const target = requestContext(name, {}, root).root;
27
+ try {
28
+ const text = (await runCoalescedRead(target, `resource:${name}`, {}, () => safeText(() => fn(target)))).text;
29
+ recordUsage(target, `resource:${name}`, "read", started, { ok: true, text });
30
+ return text;
31
+ }
32
+ catch (error) {
33
+ recordUsage(target, `resource:${name}`, "read", started, { ok: false, error: error.message });
34
+ throw error;
35
+ }
36
+ }
37
+ server.registerResource("dashboard", "pm://dashboard", { description: "项目仪表盘(与仓库根 PROJECT.md 同源),路线图/健康摘要/任务/功能清单" }, async (uri) => ({
38
+ contents: [
39
+ {
40
+ uri: uri.href,
41
+ mimeType: "text/markdown",
42
+ text: await resourceText("dashboard", (r) => fs.readFileSync(dashboardFile(r), "utf8")),
43
+ },
44
+ ],
45
+ }));
46
+ server.registerResource("roadmap", "pm://roadmap", { description: "路线图(里程碑进度 + 活跃里程碑任务明细)" }, async (uri) => ({
47
+ contents: [
48
+ {
49
+ uri: uri.href,
50
+ mimeType: "text/markdown",
51
+ text: await resourceText("roadmap", (r) => renderRoadmap(loadRoadmap(r).milestones, loadTasks(r).tasks, 2).join("\n")),
52
+ },
53
+ ],
54
+ }));
55
+ server.registerResource("tasks", "pm://tasks", { description: "任务原始数据;大列表末尾返回 continuation,可通过 list_tasks 保持 include_done:true 连续取回后续任务" }, async (uri) => ({
56
+ contents: [
57
+ {
58
+ uri: uri.href,
59
+ mimeType: "application/json",
60
+ text: await resourceText("tasks", (r) => {
61
+ const data = loadTasks(r);
62
+ const tasks = data.tasks;
63
+ // 资源也要守 token 预算:全量 dump 大账本会吃爆上下文(截断后仍是合法 JSON)
64
+ if (tasks.length <= 100)
65
+ return JSON.stringify(tasks, null, 2);
66
+ const page = taskPage(r, { include_done: true, page_size: 100 }, budgetLines(r) - 4, data);
67
+ const shown = tasks.slice(0, page.returned);
68
+ shown.push({
69
+ id: `_TRUNCATED_${tasks.length - page.returned}`,
70
+ title: `…本次输出已截断,另有 ${tasks.length - page.returned} 个任务,用 continuation 连续读取`,
71
+ continuation: { tool: "list_tasks", arguments: { include_done: true, cursor: page.next_cursor } },
72
+ });
73
+ return JSON.stringify(shown, null, 2);
74
+ }),
75
+ },
76
+ ],
77
+ }));
78
+ server.registerResource("changelog", "pm://changelog", { description: "变更日志(由 sessions.json 生成的人类可读版本)" }, async (uri) => ({
79
+ contents: [
80
+ {
81
+ uri: uri.href,
82
+ mimeType: "text/markdown",
83
+ text: await resourceText("changelog", (r) => buildChangelog(r)),
84
+ },
85
+ ],
86
+ }));
87
+ server.registerResource("architecture", "pm://architecture", { description: "跨文件/模块/语言治理审计:语义覆盖、依赖边界、接口、循环与质量矩阵" }, async (uri) => ({
88
+ contents: [{ uri: uri.href, mimeType: "text/markdown", text: await resourceText("architecture", (r) => auditGovernance(r, 150).report) }],
89
+ }));
90
+ server.registerResource("portfolio", "pm://portfolio", { description: "当前项目的组合视图与跨仓依赖/版本风险" }, async (uri) => ({
91
+ contents: [{
92
+ uri: uri.href,
93
+ mimeType: "application/json",
94
+ text: await resourceText("portfolio", (r) => JSON.stringify(buildPortfolioReport({ projects: [loadPortfolioProject(r)], projectsRequested: 1 }), null, 2)),
95
+ }],
96
+ }));
97
+ server.registerResource("acceptance", "pm://acceptance", { description: "标准化产品验收概览:版本化预批准基线与最近一次机器判定报告" }, async (uri) => ({
98
+ contents: [{
99
+ uri: uri.href,
100
+ mimeType: "application/json",
101
+ text: await resourceText("acceptance", (r) => {
102
+ const reportDir = pmPath(r, "acceptance", "reports");
103
+ let latestReport = null;
104
+ if (fs.existsSync(reportDir)) {
105
+ const candidates = fs.readdirSync(reportDir, { withFileTypes: true })
106
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
107
+ .map((entry) => ({ name: entry.name, mtime: fs.statSync(path.join(reportDir, entry.name)).mtimeMs }))
108
+ .sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name));
109
+ if (candidates[0])
110
+ latestReport = AcceptanceReportSchema.parse(JSON.parse(fs.readFileSync(path.join(reportDir, candidates[0].name), "utf8")));
111
+ }
112
+ return JSON.stringify({ baselines: listAcceptanceBaselines(r), latest_report: latestReport }, null, 2);
113
+ }),
114
+ }],
115
+ }));
116
+ }
package/dist/scan.js CHANGED
@@ -78,6 +78,10 @@ export function looksTrivialTest(content) {
78
78
  export function countSkipLines(content) {
79
79
  let n = 0;
80
80
  for (const line of content.split("\n")) {
81
+ // 排除两类误报:正则模式定义行(本扫描器源码自身)与夹具字符串模板行(含转义 \n 的内嵌代码)
82
+ const trimmed = line.trim();
83
+ if (trimmed.startsWith("/") || line.includes("\\n"))
84
+ continue;
81
85
  if (SKIP_MARKER_PATTERNS.some((re) => re.test(line)))
82
86
  n += 1;
83
87
  }
@@ -90,7 +90,7 @@ export function isLocalServerEntry(entry) {
90
90
  if (!/^node(?:\.exe)?$/i.test(path.basename(entry.command)) || entry.args.length < 1 || !path.isAbsolute(entry.args[0]))
91
91
  return false;
92
92
  const file = path.resolve(entry.args[0]);
93
- if (!/^(?:index|cli)\.(?:js|ts)$/.test(path.basename(file)))
93
+ if (!/^(?:index|cli|qoder-launch)\.(?:js|ts)$/.test(path.basename(file)))
94
94
  return false;
95
95
  try {
96
96
  const stat = fs.lstatSync(file);
package/dist/setup.js CHANGED
@@ -11,15 +11,15 @@ import { PACKAGE_SPEC, SERVER_NAME } from "./version.js";
11
11
  export { PACKAGE_SPEC, SERVER_NAME } from "./version.js";
12
12
  import { appendCodexProjectConfig } from "./setup-project.js";
13
13
  export { appendCodexProjectConfig, codexProjectServerKey } from "./setup-project.js";
14
- const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode", "workbuddy"];
14
+ const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode", "workbuddy", "qoder"];
15
15
  export function parseSetupArgs(argv) {
16
16
  const options = { client: "auto", force: false, dryRun: false, help: false, project: false };
17
17
  for (let index = 0; index < argv.length; index += 1) {
18
18
  const arg = argv[index];
19
19
  if (arg === "--client") {
20
20
  const value = argv[index + 1];
21
- if (!value || !["auto", "all", "codex", "claude", "zcode", "cursor", "vscode", "workbuddy", "print"].includes(value)) {
22
- throw new Error("--client must be auto, all, codex, claude, zcode, cursor, vscode, workbuddy, or print");
21
+ if (!value || !["auto", "all", "codex", "claude", "zcode", "cursor", "vscode", "workbuddy", "qoder", "print"].includes(value)) {
22
+ throw new Error("--client must be auto, all, codex, claude, zcode, cursor, vscode, workbuddy, qoder, or print");
23
23
  }
24
24
  options.client = value;
25
25
  index += 1;
@@ -48,8 +48,8 @@ export function parseSetupArgs(argv) {
48
48
  throw new Error(`unknown setup argument: ${arg}`);
49
49
  }
50
50
  }
51
- if (options.local && options.client !== "codex" && options.client !== "workbuddy" && options.client !== "print")
52
- throw new Error("--local 目前只支持 --client codex、workbuddy 或 print");
51
+ if (options.local && !["codex", "workbuddy", "qoder", "print"].includes(options.client))
52
+ throw new Error("--local 目前只支持 --client codex、workbuddy、qoder 或 print");
53
53
  if (options.local && options.client === "codex" && !options.project)
54
54
  throw new Error("Codex --local 需要 --project 明确项目根");
55
55
  return options;
@@ -99,6 +99,9 @@ export function detectClients(context) {
99
99
  // WorkBuddy 桌面版:`codebuddy`/`workbuddy` 命令、WORKBUDDY_CONFIG_DIR,或既有配置目录任一命中即视为已安装。
100
100
  if (context.commandExists("workbuddy") || context.commandExists("codebuddy") || Boolean(env.WORKBUDDY_CONFIG_DIR) || exists(path.join(home, ".workbuddy-ai")) || exists(path.join(home, ".workbuddy")))
101
101
  found.push("workbuddy");
102
+ // Qoder:CLI 命令、QODER_CONFIG_DIR,或既有数据目录任一命中即视为已安装。
103
+ if (context.commandExists("qoder") || context.commandExists("qoderclicn") || Boolean(env.QODER_CONFIG_DIR) || exists(path.join(home, ".qoder-cn")) || exists(path.join(home, ".qoder")))
104
+ found.push("qoder");
102
105
  return found;
103
106
  }
104
107
  function asObject(value, label) {
@@ -191,6 +194,30 @@ export function workbuddyConfigPath(env, home, exists) {
191
194
  return path.join(legacy, "mcp.json");
192
195
  return path.join(current, "mcp.json");
193
196
  }
197
+ /**
198
+ * Qoder 用户级 MCP 配置路径:`QODER_CONFIG_DIR` → `~/.qoder-cn` → `~/.qoder`。
199
+ *
200
+ * Qoder 拉起 MCP 子进程时 cwd 是安装目录,所以条目指向 `dist/qoder-launch.js` 适配器,
201
+ * 由它按会话解析工作区后再钉 `--root`;配置本身同样不钉根,一条服务所有工作区。
202
+ */
203
+ export function qoderConfigPath(env, home, exists) {
204
+ if (env.QODER_CONFIG_DIR)
205
+ return path.join(env.QODER_CONFIG_DIR, "settings.json");
206
+ const cn = path.join(home, ".qoder-cn");
207
+ if (exists(cn))
208
+ return path.join(cn, "settings.json");
209
+ const intl = path.join(home, ".qoder");
210
+ if (exists(intl))
211
+ return path.join(intl, "settings.json");
212
+ return path.join(cn, "settings.json");
213
+ }
214
+ /** 本包已构建的 Qoder 适配器入口 */
215
+ export function localQoderLaunch() {
216
+ const entry = fileURLToPath(new URL("../dist/qoder-launch.js", import.meta.url));
217
+ if (!fs.existsSync(entry) || !fs.lstatSync(entry).isFile())
218
+ throw new Error("本地入口未构建,请先 npm run build。");
219
+ return { command: process.execPath, args: [entry] };
220
+ }
194
221
  function hasCodexServer(text) {
195
222
  return /^\s*\[mcp_servers\.(?:pm-mcp|"pm-mcp")\]\s*$/m.test(text);
196
223
  }
@@ -276,6 +303,16 @@ function configureClient(client, options, context, log, projectRoot) {
276
303
  log(`[backup] ${backup}`);
277
304
  return;
278
305
  }
306
+ if (client === "qoder") {
307
+ const file = qoderConfigPath(context.env, context.home, context.exists);
308
+ log(`[${options.dryRun ? "plan" : "write"}] qoder: ${file}`);
309
+ // 发布版走 `pm-mcp launch` 适配器入口;--local 绑定本包已构建的 dist/qoder-launch.js。
310
+ const launch = options.local ? localQoderLaunch() : { command: "npx", args: ["-y", PACKAGE_SPEC, "launch"] };
311
+ const backup = writeJsonClientConfig(file, "standard", options.dryRun, launch);
312
+ if (backup)
313
+ log(`[backup] ${backup}`);
314
+ return;
315
+ }
279
316
  if (!context.commandExists("code"))
280
317
  throw new Error("code was not found in PATH");
281
318
  const definition = JSON.stringify({ name: SERVER_NAME, command: "npx", args: ["-y", PACKAGE_SPEC] });
@@ -286,11 +323,12 @@ function configureClient(client, options, context, log, projectRoot) {
286
323
  function printHelp(log) {
287
324
  log("pm-mcp setup — configure local AI coding clients");
288
325
  log("");
289
- log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|workbuddy|print] [--project [dir]] [--force] [--dry-run] [--local]`);
326
+ log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|workbuddy|qoder|print] [--project [dir]] [--force] [--dry-run] [--local]`);
290
327
  log("Default auto mode configures every detected supported client.");
291
328
  log("--project [dir]: 在 Codex 把该目录注册为钉定项目并初始化 .pm(新项目一条命令上手;默认当前目录)");
292
329
  log("--local: 使用本包已构建的本地入口,适合未发布候选;--force 仅升级已确认项目的 command/args。");
293
330
  log("workbuddy: 写入 <WorkBuddy 数据目录>/mcp.json(标准 mcpServers),不钉根——服务端按会话工作区解析项目根。");
331
+ log("qoder: 写入 <QODER_CONFIG_DIR|~/.qoder-cn>/settings.json,条目指向 qoder-launch 适配器;它按会话取工作区,一条配置服务所有项目。");
294
332
  log("升级后运行 pm-mcp doctor --root <项目目录> 核验;现有客户端仍需重新连接。");
295
333
  }
296
334
  export function runSetup(argv, overrides = {}) {
@@ -360,11 +398,16 @@ export function runSetup(argv, overrides = {}) {
360
398
  else if (selected.length === 1 && selected[0] === "workbuddy") {
361
399
  log("完成。请在 WorkBuddy 的「连接器管理 → 自定义连接器」中确认信任 pm-mcp,再重开会话调用 get_status 核验。");
362
400
  }
401
+ else if (selected.length === 1 && selected[0] === "qoder") {
402
+ log("完成。请在 Qoder 里执行 /mcp reload(或重开会话)并确认信任提示,再调用 get_status 核验项目根与解析依据。");
403
+ }
363
404
  else {
364
405
  // 多客户端时逐条给出各自的重连/授权要求,避免只提其中一个造成误导。
365
406
  const notes = [];
366
407
  if (selected.includes("workbuddy"))
367
408
  notes.push("WorkBuddy 需在「连接器管理 → 自定义连接器」确认信任 pm-mcp");
409
+ if (selected.includes("qoder"))
410
+ notes.push("Qoder 需 /mcp reload 并确认信任");
368
411
  if (selected.includes("codex"))
369
412
  notes.push("Codex 需完全退出并重启");
370
413
  log(notes.length > 0
@@ -23,12 +23,12 @@ export function verifiedTaskEvidence(root, requested, files = []) {
23
23
  const absolute = fs.realpathSync.native(path.resolve(root));
24
24
  const anchors = files.map(projectFilePath);
25
25
  if (anchors.length === 0)
26
- throw new Error("完成开发/修复任务需要通过 files 关联实际实现或测试文件。");
26
+ throw new Error("完成开发/修复任务需要通过 files 关联实际实现或测试文件。请在 update_task 里传 files: [\"src/…\", \"test/…\"](相对路径);未验证的功能/杂项任务请改用 type=chore 或 checkpoint 存档。");
27
27
  const excluded = ignoreMatcher(projectScanIgnores(root));
28
28
  for (const rel of anchors) {
29
29
  const parts = rel.split("/");
30
30
  if (rel === "PROJECT.md" || parts.some((part, index) => (index < parts.length - 1 && SCAN_IGNORE_DIRS.has(part)) || excluded(parts.slice(0, index + 1).join("/"), index < parts.length - 1))) {
31
- throw new Error(`任务文件不在源码验证范围内: ${rel}`);
31
+ throw new Error(`任务文件不在源码验证范围内: ${rel}。请改用 src/ 或 test/ 内的相对路径;.pm/、PROJECT.md、文档与 scan_ignore 排除项不能作为任务文件。`);
32
32
  }
33
33
  for (let index = 0; index < parts.length; index++) {
34
34
  const target = path.join(absolute, ...parts.slice(0, index + 1));
@@ -40,7 +40,7 @@ export function verifiedTaskEvidence(root, requested, files = []) {
40
40
  if (error.code !== "ENOENT")
41
41
  throw error;
42
42
  if (!changedGitFiles(absolute).includes(rel))
43
- throw new Error(`任务关联文件不存在,也没有 Git 删除记录: ${rel}`);
43
+ throw new Error(`任务关联文件不存在,也没有 Git 删除记录: ${rel}。请核对路径拼写(相对路径、/ 分隔),确认文件真实存在于本仓库。`);
44
44
  break;
45
45
  }
46
46
  if (stat.isSymbolicLink() || (index === parts.length - 1 ? !stat.isFile() : !stat.isDirectory())) {
@@ -51,7 +51,7 @@ export function verifiedTaskEvidence(root, requested, files = []) {
51
51
  const directory = path.join(absolute, ".pm", "quality-runs");
52
52
  for (const part of [path.join(absolute, ".pm"), directory]) {
53
53
  if (!fs.existsSync(part))
54
- throw new Error("完成开发/修复任务前请执行 run_quality_matrix,缺少质量验证报告。");
54
+ throw new Error("完成开发/修复任务前请执行 run_quality_matrix,缺少质量验证报告。先调用 run_quality_matrix(confirm_execute: true) 生成报告,全绿后再 update_task(done)。");
55
55
  const stat = fs.lstatSync(part);
56
56
  if (!stat.isDirectory() || stat.isSymbolicLink())
57
57
  throw new Error("质量证据目录必须是项目内的普通目录。");
@@ -70,9 +70,9 @@ export function verifiedTaskEvidence(root, requested, files = []) {
70
70
  }).sort((a, b) => b.runAt.localeCompare(a.runAt) || b.relative.localeCompare(a.relative));
71
71
  const latest = reports[0];
72
72
  if (!latest)
73
- throw new Error("缺少质量验证报告,请先执行 run_quality_matrix。");
73
+ throw new Error("缺少质量验证报告,请先执行 run_quality_matrix(confirm_execute: true),全绿后再 update_task(done)。");
74
74
  if (requested !== undefined && projectFilePath(requested) !== latest.relative)
75
- throw new Error("必须使用最新质量报告,不能绕过后来出现的验证结果。");
75
+ throw new Error("必须使用最新质量报告,不能绕过后来出现的验证结果。请省略 verification_run 参数(自动选最新),或用最新报告的路径重试。");
76
76
  const parsed = reportSchema.safeParse(latest.value);
77
77
  if (!parsed.success)
78
78
  throw new Error("最新质量报告无效或格式过旧,请重新执行验证。");
@@ -80,7 +80,7 @@ export function verifiedTaskEvidence(root, requested, files = []) {
80
80
  if (!report.ok || !report.execute || !report.source?.stable || report.source.before.sha256 !== report.source.after.sha256 ||
81
81
  report.results.some((item) => item.status !== "passed" || item.exit_code !== 0 || item.signal !== null || item.output_truncated ||
82
82
  [item.output_summary.failed, item.output_summary.skipped, item.output_summary.cancelled, item.output_summary.todo].some((n) => n !== null && n > 0))) {
83
- throw new Error("最新质量验证失败、未执行、输出不完整或存在跳过项,不能标记完成。");
83
+ throw new Error("最新质量验证失败、未执行、输出不完整或存在跳过项,不能标记完成。请先修复失败原因,重跑 run_quality_matrix 直到全绿,再 update_task(done)。");
84
84
  }
85
85
  const tests = report.results.filter((item) => item.kind === "test" || item.kind === "coverage");
86
86
  if (tests.length === 0 || tests.some((item) => item.output_summary.tests === 0))
@@ -100,11 +100,11 @@ export function verifiedTaskEvidence(root, requested, files = []) {
100
100
  if (unitRoots.some((unit) => !within(absolute, unit)))
101
101
  throw new Error("质量报告的执行目录不属于当前项目。");
102
102
  if (anchors.some((file) => !unitRoots.some((unit) => within(unit, path.join(absolute, file))))) {
103
- throw new Error("质量报告的测试单元未覆盖任务关联文件所在目录。");
103
+ throw new Error("质量报告的测试单元未覆盖任务关联文件所在目录。请把关联文件纳入测试覆盖,或将 files 改为质量报告已覆盖目录内的文件后重试。");
104
104
  }
105
105
  const current = fingerprintProject(absolute);
106
106
  if (current.sha256 !== report.source.after.sha256)
107
- throw new Error("质量验证对应的源码已变化,证据过期,请重新验证。");
107
+ throw new Error("质量验证对应的源码已变化,证据过期,请重新验证。重新执行 run_quality_matrix 刷新证据,全绿后再 update_task(done)。");
108
108
  const digest = createHash("sha256").update(latest.raw).digest("hex");
109
109
  return `质量报告: ${latest.relative}\n报告 SHA-256: ${digest}\n源码 SHA-256: ${current.sha256}\n执行时间: ${report.run_at}\n证据等级: ${level}${policy ? `\n项目策略: ${policy.minimum};${policy.reason}` : "\n项目策略: tests_observed(默认)"}`;
110
110
  }
@@ -10,8 +10,8 @@ import { appendSession } from "./session-log.js";
10
10
  import { verifiedTaskEvidence } from "./task-evidence.js";
11
11
  import { taskPage, taskPageText, TaskPageSchema } from "./task-pagination.js";
12
12
  const stepSchema = z.object({ text: z.string(), done: z.boolean().optional() });
13
- export function registerTaskTools(server, root) {
14
- toolW(server, root, "add_task", "添加任务。长任务务必给 steps(显式步骤清单,断点续做的载体);走了捷径 type=debt 登记(债务账);修 bug type=fix。", {
13
+ export function registerTaskTools(server, startupRoot) {
14
+ toolW(server, startupRoot, "add_task", "添加任务。长任务务必给 steps(显式步骤清单,断点续做的载体);走了捷径 type=debt 登记(债务账);修 bug type=fix。", {
15
15
  title: z.string().min(1),
16
16
  detail: z.string().optional(),
17
17
  type: z.enum(["feature", "refactor", "fix", "chore", "debt"]).optional().describe("默认 feature"),
@@ -21,7 +21,8 @@ export function registerTaskTools(server, root) {
21
21
  files: z.array(z.string()).optional().describe("关联文件(相对路径)"),
22
22
  acceptance: z.string().optional().describe("验收标准"),
23
23
  steps: z.array(stepSchema).optional().describe("步骤清单(长任务必填)"),
24
- }, (args) => {
24
+ }, (args, ctx) => {
25
+ const root = ctx.root;
25
26
  requireInitialized(root);
26
27
  const roadmap = loadRoadmap(root);
27
28
  if (args.milestone && !roadmap.milestones.some((m) => m.id === args.milestone)) {
@@ -59,7 +60,7 @@ export function registerTaskTools(server, root) {
59
60
  : "";
60
61
  return `✅ 任务 ${id}「${args.title}」已创建(${args.type ?? "feature"}/${args.milestone ?? "无里程碑"})。${tip}`;
61
62
  });
62
- toolR(server, root, "list_tasks", "分页列出任务(默认不含 done/cancelled,每页25)。按原过滤条件传 next_cursor 可取下一页;数据变化会使游标失效。文字摘要与结构化分页结果同时返回,不漏掉折叠的中间任务。", {
63
+ toolR(server, startupRoot, "list_tasks", "分页列出任务(默认不含 done/cancelled,每页25)。按原过滤条件传 next_cursor 可取下一页;数据变化会使游标失效。文字摘要与结构化分页结果同时返回,不漏掉折叠的中间任务。", {
63
64
  status: z.enum(["backlog", "todo", "in_progress", "blocked", "done", "cancelled"]).optional(),
64
65
  type: z.enum(["feature", "refactor", "fix", "chore", "debt"]).optional(),
65
66
  milestone: z.string().optional(),
@@ -67,11 +68,12 @@ export function registerTaskTools(server, root) {
67
68
  include_done: z.boolean().optional().describe("默认 false"),
68
69
  page_size: z.number().int().min(1).max(100).optional().describe("默认25,受项目输出预算限制"),
69
70
  cursor: z.string().min(1).max(1024).optional().describe("上页返回的 next_cursor;保留相同过滤条件"),
70
- }, (args) => {
71
+ }, (args, ctx) => {
72
+ const root = ctx.root;
71
73
  requireInitialized(root);
72
74
  return JSON.stringify(taskPage(root, args, budgetLines(root) - 4));
73
75
  }, { outputSchema: TaskPageSchema.shape, decode: (value) => { const page = TaskPageSchema.parse(JSON.parse(value)); return { text: taskPageText(page), data: page }; } });
74
- toolW(server, root, "update_task", "一次更新任务、checkpoint 与会话。checkpoint 默认记会话;完成时用 record_session:true 一并记录,不再另调 log_session;files 给实际关联文件。feature/fix 转 done 必须有最新成功且源码匹配的测试报告,verification_run 可指明报告。文字 verification 不能代替执行证据。", {
76
+ toolW(server, startupRoot, "update_task", "一次更新任务、checkpoint 与会话。checkpoint 默认记会话;完成时用 record_session:true 一并记录,不再另调 log_session;files 给实际关联文件。feature/fix 转 done 必须有最新成功且源码匹配的测试报告,verification_run 可指明报告。文字 verification 不能代替执行证据。", {
75
77
  id: z.string(),
76
78
  status: z.enum(["backlog", "todo", "in_progress", "blocked", "done", "cancelled"]).optional(),
77
79
  title: z.string().optional(),
@@ -89,7 +91,8 @@ export function registerTaskTools(server, root) {
89
91
  steps: z.array(stepSchema).optional().describe("整体替换步骤清单"),
90
92
  step_done: z.number().int().min(1).optional().describe("勾选完成第 N 步"),
91
93
  author: z.string().optional(),
92
- }, (args, prepared) => {
94
+ }, (args, ctx, prepared) => {
95
+ const root = ctx.root;
93
96
  requireInitialized(root);
94
97
  const data = loadTasks(root);
95
98
  const t = data.tasks.find((x) => x.id === args.id);
@@ -174,7 +177,8 @@ export function registerTaskTools(server, root) {
174
177
  refreshDerived(root);
175
178
  const stepsInfo = t.steps.length > 0 ? `,步骤 ${t.steps.filter((s) => s.done).length}/${t.steps.length}` : "";
176
179
  return [`✅ ${t.id} 已更新(${t.status}${stepsInfo})。`, ...notes].join("\n");
177
- }, (args) => {
180
+ }, (args, ctx) => {
181
+ const root = ctx.root;
178
182
  requireInitialized(root);
179
183
  const task = loadTasks(root).tasks.find((item) => item.id === args.id);
180
184
  if (!task)
@@ -187,11 +191,12 @@ export function registerTaskTools(server, root) {
187
191
  const record = args.record_session ?? (args.checkpoint !== undefined);
188
192
  return { evidence, taskSnapshot: JSON.stringify(task), hashes: record ? captureFileHashes(root, args.files ?? []) : {} };
189
193
  });
190
- toolW(server, root, "checkpoint", "断点存档:长任务做到一半(或上下文快满)时随手保存「当前进度 + 下一步动作」。新会话 get_status 会直接给出恢复点,不用靠回忆。", {
194
+ toolW(server, startupRoot, "checkpoint", "断点存档:长任务做到一半(或上下文快满)时随手保存「当前进度 + 下一步动作」。新会话 get_status 会直接给出恢复点,不用靠回忆。", {
191
195
  task_id: z.string(),
192
196
  note: z.string().describe("当前进展到哪了"),
193
197
  next_step: z.string().describe("下一个具体动作(新会话从这里续做)"),
194
- }, (args) => {
198
+ }, (args, ctx) => {
199
+ const root = ctx.root;
195
200
  requireInitialized(root);
196
201
  const data = loadTasks(root);
197
202
  const t = data.tasks.find((x) => x.id === args.task_id);
package/dist/tool-base.js CHANGED
@@ -1,7 +1,25 @@
1
+ import { z } from "zod";
1
2
  import { loadProject } from "./store.js";
2
3
  import { IdempotencyKeySchema, runCoalescedRead, runIdempotentWrite, runIdempotentWriteSync } from "./idempotency.js";
3
4
  import { drainFoldSavings } from "./budget.js";
4
5
  import { estimateTokens, logRuntime, logUsage } from "./usage-log.js";
6
+ import { resolveRequestRoot } from "./caller-workspace.js";
7
+ import { rootResolution } from "./paths.js";
8
+ /** 客户端共用一个常驻服务进程时,启动根只是默认值;每次调用都要重新认领调用方工作区 */
9
+ export function requestContext(tool, args, startupRoot) {
10
+ const resolution = resolveRequestRoot({
11
+ tool,
12
+ args,
13
+ startupRoot,
14
+ startupSource: rootResolution()?.source ?? "unknown",
15
+ });
16
+ return { root: resolution.root, rootSource: resolution.source, ...(resolution.candidates ? { rootCandidates: resolution.candidates } : {}) };
17
+ }
18
+ /** 无法自动认领工作区时的显式逃生口:任何工具都可以直接指定项目根 */
19
+ const rootOverride = z.string().optional().describe("项目根绝对路径。仅当服务端报告无法确定调用方工作区(root_source 含「未猜测」)时才需要传。");
20
+ function readSchema(schema) {
21
+ return { ...schema, root: rootOverride };
22
+ }
5
23
  /** 工具/资源调用结束后写使用日志(含本次折叠省下的 token 估算);绝不抛错。 */
6
24
  export function recordUsage(root, tool, kind, started, outcome) {
7
25
  try {
@@ -25,11 +43,13 @@ export function recordUsage(root, tool, kind, started, outcome) {
25
43
  }
26
44
  }
27
45
  /** 只读工具:跨 Agent/进程合并完全相同的并行请求,短窗复用结果。 */
28
- export function toolR(server, root, name, description, schema, fn, presentation) {
29
- server.registerTool(name, { description, inputSchema: schema, ...(presentation ? { outputSchema: presentation.outputSchema } : {}), annotations: { readOnlyHint: true } }, (async (args) => {
46
+ export function toolR(server, startupRoot, name, description, schema, fn, presentation) {
47
+ server.registerTool(name, { description, inputSchema: readSchema(schema), ...(presentation ? { outputSchema: presentation.outputSchema } : {}), annotations: { readOnlyHint: true } }, (async (args) => {
48
+ const ctx = requestContext(name, (args ?? {}), startupRoot);
49
+ const root = ctx.root;
30
50
  const started = Date.now();
31
51
  try {
32
- const result = await runCoalescedRead(root, presentation ? `${name}:${presentation.cacheVersion ?? "structured-v1"}` : name, args, fn);
52
+ const result = await runCoalescedRead(root, presentation ? `${name}:${presentation.cacheVersion ?? "structured-v1"}` : name, args, () => fn(args, ctx));
33
53
  const decoded = presentation?.decode(result.text);
34
54
  const text = decoded?.text ?? result.text;
35
55
  recordUsage(root, name, "read", started, {
@@ -53,15 +73,18 @@ const idempotencyDescription = "多 Agent 执行同一业务时传相同 idempot
53
73
  function writeSchema(schema) {
54
74
  return {
55
75
  ...schema,
76
+ root: rootOverride,
56
77
  idempotency_key: IdempotencyKeySchema.optional().describe("跨 Agent 业务幂等键,建议格式 task-id:operation,如 T-123:add-task"),
57
78
  };
58
79
  }
59
80
  /** 同步写工具:业务幂等占位 + 跨进程账本锁覆盖完整读-改-写。 */
60
- export function toolW(server, root, name, description, schema, fn, prepare) {
81
+ export function toolW(server, startupRoot, name, description, schema, fn, prepare) {
61
82
  server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, ((args) => {
83
+ const ctx = requestContext(name, (args ?? {}), startupRoot);
84
+ const root = ctx.root;
62
85
  const started = Date.now();
63
86
  try {
64
- const result = runIdempotentWriteSync(root, name, args, fn, prepare);
87
+ const result = runIdempotentWriteSync(root, name, args, (value, prepared) => fn(value, ctx, prepared), prepare ? (value) => prepare(value, ctx) : undefined);
65
88
  const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
66
89
  const text = prefix + result.text;
67
90
  recordUsage(root, name, "write", started, {
@@ -82,11 +105,13 @@ export function toolW(server, root, name, description, schema, fn, prepare) {
82
105
  }));
83
106
  }
84
107
  /** 已自带内部锁或异步执行的写工具:先跨进程占位,完成后缓存结果。 */
85
- export function toolI(server, root, name, description, schema, fn) {
108
+ export function toolI(server, startupRoot, name, description, schema, fn) {
86
109
  server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, (async (args) => {
110
+ const ctx = requestContext(name, (args ?? {}), startupRoot);
111
+ const root = ctx.root;
87
112
  const started = Date.now();
88
113
  try {
89
- const result = await runIdempotentWrite(root, name, args, fn);
114
+ const result = await runIdempotentWrite(root, name, args, (value) => fn(value, ctx));
90
115
  const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
91
116
  const text = prefix + result.text;
92
117
  recordUsage(root, name, "write-async", started, {
@@ -13,6 +13,18 @@ function scriptKind(rel) {
13
13
  function stringValue(node) {
14
14
  return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : null;
15
15
  }
16
+ function insideEvidenceProperty(node) {
17
+ let current = node;
18
+ while (current.parent && (ts.isArrayLiteralExpression(current.parent) || ts.isSpreadElement(current.parent))) {
19
+ current = current.parent;
20
+ }
21
+ const parent = current.parent;
22
+ if (!parent || !ts.isPropertyAssignment(parent))
23
+ return false;
24
+ const name = parent.name;
25
+ const text = ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
26
+ return text !== undefined && /evidence/i.test(text);
27
+ }
16
28
  function bindingNames(name) {
17
29
  if (ts.isIdentifier(name))
18
30
  return [name.text];
@@ -239,16 +251,22 @@ export function analyzeTypeScriptSource(rel, content) {
239
251
  addReference(node, "grpc", firstArgument, 0.86);
240
252
  }
241
253
  else if (ts.isStringLiteralLike(node)) {
242
- const value = node.text;
243
- const pathPart = value.split("#", 1)[0].replace(/\\/g, "/");
244
- const basename = pathPart.split("/").at(-1) ?? "";
245
- // Extension constants such as ".proto" are metadata, not contract links.
246
- // Requiring a real basename keeps AST evidence precise while still
247
- // accepting both nearby files (service.proto) and relative paths.
248
- if (/^[^.\s][^/\s]*\.(?:proto|graphql|gql)$/i.test(basename))
249
- addReference(node, "contract", value, 0.98, true);
250
- else if (/^[^.\s][^/\s]*(?:openapi|swagger|asyncapi|contracts?|schemas?)[^/\s]*\.(?:ya?ml|json)$/i.test(basename))
251
- addReference(node, "contract", value, 0.95, true);
254
+ // Structured evidence fields (evidenceRef / lastEvidenceRef / evidence: [...]) carry
255
+ // data payloads pointing at evidence artifacts, not module specifiers; a path value
256
+ // like "apps/<pkg>/evidence/...-schema.json" must not become a contract link
257
+ // (upstream report T-095: phantom taiji-beidou-engine -> ai-gateway-service edge).
258
+ if (!insideEvidenceProperty(node)) {
259
+ const value = node.text;
260
+ const pathPart = value.split("#", 1)[0].replace(/\\/g, "/");
261
+ const basename = pathPart.split("/").at(-1) ?? "";
262
+ // Extension constants such as ".proto" are metadata, not contract links.
263
+ // Requiring a real basename keeps AST evidence precise while still
264
+ // accepting both nearby files (service.proto) and relative paths.
265
+ if (/^[^.\s][^/\s]*\.(?:proto|graphql|gql)$/i.test(basename))
266
+ addReference(node, "contract", value, 0.98, true);
267
+ else if (/^[^.\s][^/\s]*(?:openapi|swagger|asyncapi|contracts?|schemas?)[^/\s]*\.(?:ya?ml|json)$/i.test(basename))
268
+ addReference(node, "contract", value, 0.95, true);
269
+ }
252
270
  }
253
271
  else if (ts.isTaggedTemplateExpression(node) && /^(?:gql|graphql)$/.test(expressionPath(node.tag))) {
254
272
  addReference(node, "contract", "inline GraphQL", 0.96);
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@ export const PACKAGE_SPEC = `${metadata.name}@${VERSION}`;
7
7
  export const RUNTIME_CONTRACT = "pagination-evidence-v1";
8
8
  // 服务器工具目录契约(doctor 与独立巡检 health-check 都以此核对)。
9
9
  // 新增/移除 MCP 工具时必须同步更新这里,并用 `pm-mcp probe` 实测复核。
10
- export const TOOL_CATALOG_SIZE = 52;
10
+ export const TOOL_CATALOG_SIZE = 54;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckychen1993/pm-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "AI 编码项目的单一事实来源 + 健康台账 MCP 服务(事实来源 / 路线图 / 防幻觉 / 七本账 / 断点续做 / token 经济)",
5
5
  "keywords": [
6
6
  "mcp",