@luckychen1993/pm-mcp 0.1.4 → 0.3.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.
package/dist/doctor.js ADDED
@@ -0,0 +1,148 @@
1
+ import os from "node:os";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ import { PACKAGE_SPEC, RUNTIME_CONTRACT, SERVER_NAME, TOOL_CATALOG_SIZE, VERSION } from "./version.js";
7
+ import { canonicalProjectRoot, findCodexProjectEntry, isLocalLaunch, isLocalServerEntry, isPackageLaunch, readCodexConfig } from "./setup-project.js";
8
+ import { localProjectLaunch, workbuddyConfigPath } from "./setup.js";
9
+ export function parseDoctorArgs(argv) {
10
+ const options = { root: process.cwd() };
11
+ for (let index = 0; index < argv.length; index++) {
12
+ const arg = argv[index], value = argv[index + 1];
13
+ if (!["--root", "--entry", "--config", "--client"].includes(arg) || !value || value.startsWith("--"))
14
+ throw new Error(`doctor 参数无效: ${arg}`);
15
+ if (arg === "--root")
16
+ options.root = path.resolve(value);
17
+ if (arg === "--entry")
18
+ options.entry = path.resolve(value);
19
+ if (arg === "--config")
20
+ options.config = path.resolve(value);
21
+ if (arg === "--client") {
22
+ if (value !== "codex" && value !== "workbuddy")
23
+ throw new Error("doctor 支持 --client codex|workbuddy,其他客户端可用 --entry 验证本地入口。");
24
+ options.client = value;
25
+ }
26
+ index++;
27
+ }
28
+ if (options.entry && options.config)
29
+ throw new Error("--entry 与 --config 不能同时使用。");
30
+ return options;
31
+ }
32
+ /**
33
+ * 从 WorkBuddy 的 `mcp.json` 中取出 pm-mcp 条目。
34
+ *
35
+ * 只读取启动定义(command/args),不复制也不输出任何自定义 env / 凭据。
36
+ * 文件必须是**有界普通文件**:链接、目录、超 2MB 一律拒绝。
37
+ */
38
+ function readWorkbuddyEntry(file) {
39
+ if (!fs.existsSync(file))
40
+ return undefined;
41
+ const stat = fs.lstatSync(file);
42
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024)
43
+ throw new Error("WorkBuddy 配置必须为有界普通文件,禁止链接。");
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
47
+ }
48
+ catch (error) {
49
+ throw new Error(`WorkBuddy 配置不是合法 JSON:${error.message}`);
50
+ }
51
+ const servers = parsed?.mcpServers;
52
+ const entry = servers?.[SERVER_NAME];
53
+ if (!entry)
54
+ return undefined;
55
+ if (entry.disabled === true)
56
+ throw new Error("WorkBuddy 中 pm-mcp 条目被禁用,现有客户端不会启动它。");
57
+ if (typeof entry.command !== "string" || entry.command.length === 0)
58
+ throw new Error("WorkBuddy 中 pm-mcp 条目缺少可识别的 command。");
59
+ const args = entry.args === undefined ? [] : entry.args;
60
+ if (!Array.isArray(args) || !args.every((value) => typeof value === "string"))
61
+ throw new Error("WorkBuddy 中 pm-mcp 条目的 args 不是字符串数组。");
62
+ return { command: entry.command, args: args };
63
+ }
64
+ /** 短期本地读连接;不把探测新进程冒充已重新连接的客户端。 */
65
+ export async function runDoctor(options) {
66
+ const root = path.resolve(options.root);
67
+ const report = {
68
+ ok: false, expected_version: VERSION, root, configuration: options.entry ? "explicit_entry" : (options.client ?? "codex"), issues: [],
69
+ existing_client: "not_observed", note: "这是新建的本地探测连接。现有客户端须重新连接,并在其中调用 get_status 核对运行版本;未读取或转发自定义凭据环境。",
70
+ };
71
+ let launch;
72
+ try {
73
+ if (options.entry)
74
+ launch = { command: process.execPath, args: [options.entry, "--root", root] };
75
+ else if (options.client === "workbuddy") {
76
+ const file = options.config ?? workbuddyConfigPath(process.env, os.homedir(), fs.existsSync);
77
+ const entry = readWorkbuddyEntry(file);
78
+ if (!entry)
79
+ throw new Error("未在 WorkBuddy 的 mcp.json 中找到 pm-mcp 条目。");
80
+ report.configured_server = SERVER_NAME;
81
+ if (!isLocalServerEntry(entry))
82
+ throw new Error("WorkBuddy 中的 pm-mcp 不是可识别的本地 pm-mcp 启动项;未执行未知启动命令。");
83
+ // 配置通常不钉根(由会话工作区决定);探测需要明确目标根,故补上 --root。
84
+ launch = entry.args.includes("--root") ? entry : { command: entry.command, args: [...entry.args, "--root", root] };
85
+ report.configuration = "workbuddy_local_probe";
86
+ }
87
+ else {
88
+ if (!options.config && fs.existsSync(path.join(root, ".codex", "config.toml")))
89
+ throw new Error("存在项目级 Codex 配置,可能覆盖全局设置;请用 --config 明确核验的配置文件并检查实际客户端。");
90
+ const file = options.config ?? path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
91
+ const entry = findCodexProjectEntry(readCodexConfig(file), root);
92
+ if (!entry)
93
+ throw new Error("未找到已明确绑定此项目的 Codex pm-mcp 配置。");
94
+ report.configured_server = entry.name;
95
+ if (!entry.enabled)
96
+ throw new Error("该服务在配置中被禁用,现有客户端不会启动它。");
97
+ if (entry.toolFilters)
98
+ throw new Error(`存在工具过滤设置,请核对后再确认全部${TOOL_CATALOG_SIZE}工具可用。`);
99
+ if (isPackageLaunch(entry)) {
100
+ if (entry.args[1] !== PACKAGE_SPEC)
101
+ throw new Error(`启动配置版本与本包 ${PACKAGE_SPEC} 不一致,请先预览并升级配置。`);
102
+ launch = localProjectLaunch(root);
103
+ report.configuration = "codex_package_matches_local_probe";
104
+ }
105
+ else
106
+ launch = entry;
107
+ }
108
+ if (!isLocalLaunch(launch))
109
+ throw new Error("探测入口不是可识别的本地 pm-mcp 包;不执行未知启动命令。");
110
+ }
111
+ catch (error) {
112
+ report.issues.push(error.message);
113
+ return report;
114
+ }
115
+ const client = new Client({ name: "pm-mcp-doctor", version: VERSION });
116
+ const transport = new StdioClientTransport({ command: launch.command, args: launch.args,
117
+ env: { PM_MCP_HOME: path.join(root, ".pm", ".runtime", "doctor-home") }, stderr: "pipe" });
118
+ const timer = setTimeout(() => { void client.close(); }, 20_000);
119
+ timer.unref();
120
+ try {
121
+ await client.connect(transport, { timeout: 10_000 });
122
+ transport.stderr?.on("data", () => undefined);
123
+ const info = client.getServerVersion();
124
+ const inventory = await client.listTools({}, { timeout: 10_000 });
125
+ const status = await client.callTool({ name: "get_status", arguments: {} }, undefined, { timeout: 10_000 });
126
+ const runtime = status.structuredContent?.runtime;
127
+ report.probe = { server: info?.name, version: info?.version, tools: inventory.tools.length, runtime };
128
+ if (info?.name !== SERVER_NAME || info.version !== VERSION)
129
+ report.issues.push("握手返回的服务名或运行版本不匹配。");
130
+ if (status.isError || typeof runtime?.root !== "string" || canonicalProjectRoot(runtime.root) !== canonicalProjectRoot(root))
131
+ report.issues.push("实际状态读取未确认目标项目根。");
132
+ if (runtime?.version !== VERSION || runtime?.contract !== RUNTIME_CONTRACT)
133
+ report.issues.push("实际运行契约不支持本轮升级,不能仅凭配置宣称生效。");
134
+ const list = inventory.tools.find((tool) => tool.name === "list_tasks");
135
+ const project = inventory.tools.find((tool) => tool.name === "update_project");
136
+ if (inventory.tools.length !== TOOL_CATALOG_SIZE || !list?.inputSchema.properties?.cursor || !project?.inputSchema.properties?.completion_evidence)
137
+ report.issues.push("工具目录或新参数契约不完整。");
138
+ report.ok = report.issues.length === 0;
139
+ }
140
+ catch (error) {
141
+ report.issues.push(`本地连接验证失败: ${error.message}`);
142
+ }
143
+ finally {
144
+ clearTimeout(timer);
145
+ await client.close();
146
+ }
147
+ return report;
148
+ }
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawnSync } from "node:child_process";
5
+ import { normSep } from "./budget.js";
6
+ import { hashRegularFile } from "./project-fingerprint.js";
7
+ export function projectFilePath(relative) {
8
+ const rel = path.posix.normalize(normSep(relative));
9
+ if (!relative || path.isAbsolute(rel) || /^[a-z]:/i.test(rel) || rel === "." || rel === ".." || rel.startsWith("../")) {
10
+ throw new Error("文件必须是项目内的相对路径");
11
+ }
12
+ return rel;
13
+ }
14
+ /** 使用 NUL 分隔,避免中文转义、空格及文件名中的箭头被误解析。 */
15
+ export function changedGitFiles(root) {
16
+ const result = spawnSync("git", ["status", "--porcelain=v1", "-z", "-uall"], { cwd: root, encoding: "utf8", timeout: 10000, maxBuffer: 16 * 1024 * 1024 });
17
+ if (result.error || result.status !== 0)
18
+ throw new Error(result.error?.message ?? `git status 退出码 ${result.status}`);
19
+ const records = result.stdout.split("\0");
20
+ const changed = new Set();
21
+ const add = (file) => {
22
+ const rel = projectFilePath(file);
23
+ if (!rel.startsWith(".pm/") && rel !== "PROJECT.md" && rel !== ".gitignore")
24
+ changed.add(rel);
25
+ };
26
+ for (let i = 0; i < records.length; i++) {
27
+ const row = records[i];
28
+ if (!row)
29
+ continue;
30
+ add(row.slice(3));
31
+ const status = row.slice(0, 2);
32
+ if (/[RC]/.test(status)) {
33
+ const original = records[++i]; // -z 先输出目标,再输出原路径
34
+ if (/R/.test(status))
35
+ add(original); // 重命名还需要核对旧路径的删除;复制不改原文件
36
+ }
37
+ }
38
+ return [...changed];
39
+ }
40
+ /** 只对调用者声明的文件流式取摘要,不跟随父目录链接或读取链接指向的内容。 */
41
+ export function captureFileHashes(root, files) {
42
+ const hashes = Object.create(null);
43
+ for (const file of files) {
44
+ const rel = projectFilePath(file);
45
+ let current = path.resolve(root);
46
+ let signature = "missing";
47
+ const parts = rel.split("/");
48
+ for (let index = 0; index < parts.length; index++) {
49
+ current = path.join(current, parts[index]);
50
+ let stat;
51
+ try {
52
+ stat = fs.lstatSync(current);
53
+ }
54
+ catch (error) {
55
+ if (error.code === "ENOENT")
56
+ break;
57
+ throw error;
58
+ }
59
+ if (stat.isSymbolicLink()) {
60
+ if (index !== parts.length - 1)
61
+ throw new Error(`文件路径穿过符号链接: ${rel}`);
62
+ signature = `link\0${fs.readlinkSync(current)}`;
63
+ }
64
+ else if (index === parts.length - 1) {
65
+ if (!stat.isFile())
66
+ throw new Error(`会话文件必须是普通文件或已删除文件: ${rel}`);
67
+ signature = `file\0${hashRegularFile(current).sha256}`;
68
+ }
69
+ else if (!stat.isDirectory())
70
+ throw new Error(`文件路径不是目录: ${rel}`);
71
+ }
72
+ hashes[rel] = createHash("sha256").update(signature).digest("hex");
73
+ }
74
+ return hashes;
75
+ }
@@ -10,8 +10,9 @@ import { impactAnalysis } from "./semantic-graph.js";
10
10
  import { assessQualityCoverage, createQualityPlan, discoverProjectUnits, runQualityPlan } from "./language-adapters.js";
11
11
  import { buildPortfolioFromRegistry, buildPortfolioReport, loadPortfolioProject } from "./portfolio.js";
12
12
  import { saveQualityRun } from "./quality-store.js";
13
+ import { evidenceLabel, qualityResultSummary } from "./quality-evidence.js";
13
14
  import { SemanticEvidenceDocumentSchema } from "./semantic-evidence.js";
14
- import { listSemanticEvidence, saveSemanticEvidence } from "./semantic-evidence-store.js";
15
+ import { listSemanticEvidence, replaceSemanticEvidenceForFile, saveSemanticEvidence } from "./semantic-evidence-store.js";
15
16
  import { fingerprintProject } from "./project-fingerprint.js";
16
17
  const id = z.string().trim().min(1);
17
18
  const texts = z.array(z.string().trim().min(1));
@@ -159,6 +160,14 @@ export function registerGovernanceTools(server, root) {
159
160
  requireInitialized(root);
160
161
  return JSON.stringify(listSemanticEvidence(root), null, 2);
161
162
  });
163
+ toolI(server, root, "replace_semantic_evidence_for_file", "按源码文件原子替换语义证据:删除该文件旧 evidence,仅保留当前 hash-bound evidence。", {
164
+ id: z.string().trim().min(1).max(128),
165
+ document: SemanticEvidenceDocumentSchema,
166
+ }, (args) => {
167
+ requireInitialized(root);
168
+ const saved = replaceSemanticEvidenceForFile(root, args.id, args.document);
169
+ return `✅ semantic evidence replaced ${args.id}:${saved.file} · ${saved.analyzer.id} · ${saved.analyzer.assurance} · ${saved.status}`;
170
+ });
162
171
  toolI(server, root, "save_semantic_evidence", "保存一份绑定源码 SHA-256 的语言原生 AST/编译器/运行时证据;过期摘要在治理审计中严格失败。", {
163
172
  id: z.string().trim().min(1).max(128),
164
173
  document: SemanticEvidenceDocumentSchema,
@@ -216,7 +225,8 @@ export function registerGovernanceTools(server, root) {
216
225
  if (!sourceStable)
217
226
  lines.push(`- 🚩 源码在执行期间变化:before=${sourceBefore.sha256} after=${sourceAfter.sha256}`);
218
227
  for (const item of result.results)
219
- lines.push(`- [${item.status}] ${item.command.kind} ${item.command.cwd} (${item.durationMs}ms${item.exitCode === null ? "" : `, exit ${item.exitCode}`})`);
228
+ lines.push(`- [${item.status}] ${item.command.kind} ${item.command.cwd} (${item.durationMs}ms${item.exitCode === null ? "" : `, exit ${item.exitCode}`}) · ${evidenceLabel(qualityResultSummary(item).evidence_level)}`);
229
+ lines.push("命令执行结果与任务完成证据分开判断;未知计数不等于已观察测试通过。");
220
230
  return foldLines(lines, { maxLines: budgetLines(root), hint: "查看 .pm/quality-runs 结构化摘要(不落原始输出)" });
221
231
  });
222
232
  toolR(server, root, "get_portfolio", "聚合当前项目或全局注册项目的阶段、里程碑、债务、安全、语言、跨仓依赖/版本/cycle;加载失败不能假绿。", {
package/dist/index-db.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { pmPath } from "./paths.js";
5
+ import { watcherIsClean } from "./watcher-coordinator.js";
5
6
  /** Low-level SQLite lifecycle, metadata, row iteration, and aggregate queries. */
6
7
  const SCHEMA = `
7
8
  CREATE TABLE IF NOT EXISTS files(
@@ -95,3 +96,42 @@ export function aggregates(db) {
95
96
  indexCoverageBase: contentOk.c,
96
97
  };
97
98
  }
99
+ const BEAT_STALE_MS = 90_000;
100
+ export function freshness(root) {
101
+ const db = getIndex(root);
102
+ const files = db.prepare(`SELECT COUNT(*) c FROM files`).get().c;
103
+ const lastWalk = getMeta(db, "lastWalk");
104
+ const lastBeat = getMeta(db, "lastBeat");
105
+ const mode = (getMeta(db, "mode") ?? "walk");
106
+ const pending = Number(getMeta(db, "pending") ?? 0);
107
+ const beatAge = lastBeat ? Date.now() - Date.parse(lastBeat) : Number.POSITIVE_INFINITY;
108
+ // rootPath 守卫:索引库随项目目录被拷贝/移动时,旧心跳与行集都属于原路径——不可信任
109
+ const owned = getMeta(db, "rootPath") === path.resolve(root);
110
+ const lastEvent = getMeta(db, "lastEvent");
111
+ const eventAge = lastEvent ? Date.now() - Date.parse(lastEvent) : Number.POSITIVE_INFINITY;
112
+ const watcherSession = getMeta(db, "watcherSession");
113
+ const sessionReconciled = watcherSession !== null && getMeta(db, "lastWalkSession") === watcherSession;
114
+ const watcherHealthy = !(getMeta(db, "watcherError") ?? "");
115
+ const watcherClean = watcherIsClean(root);
116
+ // 卡死计数自愈:防抖 150ms,若计数>0 但 30s 无任何事件推进,说明计数器失真(如持锁期抛错),不再信任
117
+ const pendingClean = pending === 0 || eventAge > 30_000;
118
+ const fresh = files > 0 &&
119
+ lastWalk !== null &&
120
+ owned &&
121
+ mode === "watcher" &&
122
+ sessionReconciled &&
123
+ watcherHealthy &&
124
+ watcherClean &&
125
+ pendingClean &&
126
+ beatAge < BEAT_STALE_MS;
127
+ return { mode, fresh, files, lastWalk, lastBeat, pendingEvents: pending };
128
+ }
129
+ /** 巡检/诊断用:索引概况一行 */
130
+ export function indexSummary(root) {
131
+ const f = freshness(root);
132
+ if (f.files === 0)
133
+ return "索引:空(未走查)";
134
+ const age = f.lastWalk ? `,走查于 ${f.lastWalk.slice(0, 16).replace("T", " ")}` : "";
135
+ const beat = f.fresh ? ",watcher 保鲜中" : `(watcher ${f.mode === "watcher" ? "心跳过期" : "未运行"},下次审计将全量走查)`;
136
+ return `索引:${f.files} 文件${age}${beat}`;
137
+ }
@@ -3,9 +3,10 @@ import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { isInitialized } from "./paths.js";
5
5
  import { normSep } from "./budget.js";
6
- import { getIndex, getMeta, setMeta } from "./index-db.js";
6
+ import { ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
7
+ import { freshness, getIndex, getMeta, setMeta } from "./index-db.js";
7
8
  import { clearWatcherDirty, coordinateWatcherLeader, markWatcherDirty, watcherIsClean } from "./watcher-coordinator.js";
8
- export { aggregates, closeIndex, getIndex, getMeta, iterateFileRows, setMeta } from "./index-db.js";
9
+ export { aggregates, closeIndex, freshness, getIndex, getMeta, indexSummary, iterateFileRows, setMeta } from "./index-db.js";
9
10
  import { BINARY_EXTS, countLoc, isLockfile, isTestFile, extOf, countSkipLines, needsSkipCount, looksTrivialTest, DEFAULT_IGNORE_DIRS, walkStatEntries, } from "./scan.js";
10
11
  /**
11
12
  * SQLite 扫描索引(超大项目核心):
@@ -65,6 +66,7 @@ export function deleteSubtree(db, prefix) {
65
66
  db.prepare("DELETE FROM files WHERE rel LIKE ?").run(prefix.replace(/[%_]/g, (c) => "\\" + c) + "/%");
66
67
  }
67
68
  export function walkRefresh(root, opts = {}) {
69
+ const scanPolicy = JSON.stringify(projectScanIgnores(root));
68
70
  const db = getIndex(root);
69
71
  db.exec("CREATE TEMP TABLE IF NOT EXISTS walked(rel TEXT PRIMARY KEY, mtime REAL, size INTEGER)");
70
72
  db.exec("DELETE FROM walked");
@@ -152,6 +154,7 @@ export function walkRefresh(root, opts = {}) {
152
154
  setMeta(db, "lastWalk", new Date().toISOString());
153
155
  setMeta(db, "skippedDeep", String(skippedDeep));
154
156
  setMeta(db, "rootPath", path.resolve(root));
157
+ setMeta(db, "scanPolicy", scanPolicy);
155
158
  // 走查刚完成的这一刻索引就是最新的:watcher 模式下立即续心跳,
156
159
  // 否则长走查期间(库忙)心跳可能一直写不进,走查一结束就被判"过期"再走一遍
157
160
  if (getMeta(db, "mode") === "watcher") {
@@ -167,41 +170,16 @@ export function walkRefresh(root, opts = {}) {
167
170
  activeWatchers.get(path.resolve(root))?.reconciled();
168
171
  return { totalFiles: total, changed: processed, deleted: Number(del.changes ?? 0), hits: total - processed, skippedDeep };
169
172
  }
170
- const BEAT_STALE_MS = 90_000;
171
- export function freshness(root) {
172
- const db = getIndex(root);
173
- const files = db.prepare(`SELECT COUNT(*) c FROM files`).get().c;
174
- const lastWalk = getMeta(db, "lastWalk");
175
- const lastBeat = getMeta(db, "lastBeat");
176
- const mode = (getMeta(db, "mode") ?? "walk");
177
- const pending = Number(getMeta(db, "pending") ?? 0);
178
- const beatAge = lastBeat ? Date.now() - Date.parse(lastBeat) : Number.POSITIVE_INFINITY;
179
- // rootPath 守卫:索引库随项目目录被拷贝/移动时,旧心跳与行集都属于原路径——不可信任
180
- const owned = getMeta(db, "rootPath") === path.resolve(root);
181
- const lastEvent = getMeta(db, "lastEvent");
182
- const eventAge = lastEvent ? Date.now() - Date.parse(lastEvent) : Number.POSITIVE_INFINITY;
183
- const watcherSession = getMeta(db, "watcherSession");
184
- const sessionReconciled = watcherSession !== null && getMeta(db, "lastWalkSession") === watcherSession;
185
- const watcherHealthy = !(getMeta(db, "watcherError") ?? "");
186
- const watcherClean = watcherIsClean(root);
187
- // 卡死计数自愈:防抖 150ms,若计数>0 但 30s 无任何事件推进,说明计数器失真(如持锁期抛错),不再信任
188
- const pendingClean = pending === 0 || eventAge > 30_000;
189
- const fresh = files > 0 &&
190
- lastWalk !== null &&
191
- owned &&
192
- mode === "watcher" &&
193
- sessionReconciled &&
194
- watcherHealthy &&
195
- watcherClean &&
196
- pendingClean &&
197
- beatAge < BEAT_STALE_MS;
198
- return { mode, fresh, files, lastWalk, lastBeat, pendingEvents: pending };
199
- }
173
+ /* ------------------------------ watcher 新鲜度 ------------------------------ */
200
174
  /**
201
175
  * 确保索引可用:watcher 活跃且心跳新鲜 → 直接用(稳态免走查);
202
176
  * 否则精确全量走查(冷启动/巡检进程/server 未运行时)。
203
177
  */
204
178
  export function ensureFresh(root) {
179
+ if (getMeta(getIndex(root), "scanPolicy") !== JSON.stringify(projectScanIgnores(root))) {
180
+ walkRefresh(root, { forceContent: true });
181
+ return { used: "walk", freshness: freshness(root) };
182
+ }
205
183
  let f = freshness(root);
206
184
  if (!f.fresh && f.mode === "watcher" && f.pendingEvents > 0) {
207
185
  // 变更风暴刚过:事件还在防抖队列里。本进程有 watcher 时就地排空(毫秒级),
@@ -248,12 +226,15 @@ export function startWatcher(root) {
248
226
  let leader = false;
249
227
  let stopped = false;
250
228
  let locallyUnhealthy = false;
229
+ let policy = projectScanIgnores(abs);
230
+ let customIgnored = ignoreMatcher(policy);
251
231
  const active = {
252
232
  timers,
253
233
  closed: () => stopped || !leader,
254
- reconciled: () => { locallyUnhealthy = false; },
234
+ reconciled: () => { locallyUnhealthy = false; policy = projectScanIgnores(abs); customIgnored = ignoreMatcher(policy); },
255
235
  };
256
- const ignored = (rel) => DEFAULT_IGNORE_DIRS.has(rel.split("/")[0]) || rel.split("/")[0] === ".pm";
236
+ const ignored = (rel) => DEFAULT_IGNORE_DIRS.has(rel.split("/")[0]) ||
237
+ rel.split("/").slice(0, -1).some((part) => DEFAULT_IGNORE_DIRS.has(part)) || customIgnored(rel, true);
257
238
  const recordWatcherError = (stage, error) => {
258
239
  locallyUnhealthy = true;
259
240
  try {
@@ -291,6 +272,12 @@ export function startWatcher(root) {
291
272
  return;
292
273
  let succeeded = false;
293
274
  try {
275
+ if (ignored(rel)) {
276
+ deleteFile(db, rel);
277
+ deleteSubtree(db, rel);
278
+ succeeded = true;
279
+ return;
280
+ }
294
281
  const full = path.join(abs, ...rel.split("/"));
295
282
  let st;
296
283
  try {
@@ -362,11 +349,24 @@ export function startWatcher(root) {
362
349
  db = getIndex(abs);
363
350
  try {
364
351
  watcher = fs.watch(abs, { recursive: true }, (_event, filename) => {
365
- if (stopped || !leader || !filename)
352
+ if (stopped || !leader)
353
+ return;
354
+ if (!filename) {
355
+ recordWatcherError("incomplete event", new Error("监视器未提供文件名,需要精确走查恢复索引。"));
366
356
  return;
357
+ }
367
358
  let rel;
368
359
  try {
369
360
  rel = normSep(String(filename));
361
+ if (rel === ".pm/project.json") {
362
+ const currentPolicy = projectScanIgnores(abs);
363
+ if (JSON.stringify(currentPolicy) !== JSON.stringify(policy)) {
364
+ policy = currentPolicy;
365
+ customIgnored = ignoreMatcher(policy);
366
+ markWatcherDirty(abs);
367
+ }
368
+ return;
369
+ }
370
370
  if (ignored(rel))
371
371
  return;
372
372
  }
@@ -438,13 +438,4 @@ export function startWatcher(root) {
438
438
  coordinator.stop();
439
439
  } } };
440
440
  }
441
- /** 巡检/诊断用:索引概况一行 */
442
- export function indexSummary(root) {
443
- const f = freshness(root);
444
- if (f.files === 0)
445
- return "索引:空(未走查)";
446
- const age = f.lastWalk ? `,走查于 ${f.lastWalk.slice(0, 16).replace("T", " ")}` : "";
447
- const beat = f.fresh ? ",watcher 保鲜中" : `(watcher ${f.mode === "watcher" ? "心跳过期" : "未运行"},下次审计将全量走查)`;
448
- return `索引:${f.files} 文件${age}${beat}`;
449
- }
450
441
  export { isInitialized };
package/dist/index.js CHANGED
@@ -15,6 +15,11 @@ 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";
20
+ import { VERSION } from "./version.js";
21
+ import { taskPage } from "./task-pagination.js";
22
+ import { budgetLines } from "./tool-base.js";
18
23
  /* --root 参数 > PM_ROOT 环境变量 > 启动时工作目录 */
19
24
  const argv = process.argv.slice(2);
20
25
  let explicitRoot;
@@ -23,8 +28,9 @@ for (let i = 0; i < argv.length; i++) {
23
28
  explicitRoot = argv[i + 1];
24
29
  }
25
30
  const root = resolveRoot(explicitRoot);
26
- const server = new McpServer({ name: "pm-mcp", version: "0.1.4" }, {
31
+ const server = new McpServer({ name: "pm-mcp", version: VERSION }, {
27
32
  instructions: [
33
+ `pm-mcp ${VERSION}。get_status 返回实际运行版本与项目根;配置修改后须重连核验。list_tasks 有 has_more 时保持过滤条件并使用 next_cursor 续取,不能把一页当全量。feature/fix 默认要求 tests_observed,未知计数不冒充测试通过。`,
28
34
  "多 Agent 规则:相同业务的所有写工具调用必须携带相同 idempotency_key(建议 task-id:operation);同键同参数只执行一次,同键不同参数会被拒绝。",
29
35
  "完全相同的并行读请求会由服务端合并并复用结果;不要让多个分支重复请求同一工具和参数。",
30
36
  "看到“幂等复用”时直接采用首次结果;看到“幂等占位/正在执行”时等待负责该业务的 Agent,不要换键重试。",
@@ -41,7 +47,16 @@ function safeText(fn) {
41
47
  }
42
48
  }
43
49
  async function resourceText(name, fn) {
44
- return (await runCoalescedRead(root, `resource:${name}`, {}, () => safeText(fn))).text;
50
+ const started = Date.now();
51
+ try {
52
+ const text = (await runCoalescedRead(root, `resource:${name}`, {}, () => safeText(fn))).text;
53
+ recordUsage(root, `resource:${name}`, "read", started, { ok: true, text });
54
+ return text;
55
+ }
56
+ catch (error) {
57
+ recordUsage(root, `resource:${name}`, "read", started, { ok: false, error: error.message });
58
+ throw error;
59
+ }
45
60
  }
46
61
  server.registerResource("dashboard", "pm://dashboard", { description: "项目仪表盘(与仓库根 PROJECT.md 同源),路线图/健康摘要/任务/功能清单" }, async (uri) => ({
47
62
  contents: [
@@ -61,20 +76,23 @@ server.registerResource("roadmap", "pm://roadmap", { description: "路线图(
61
76
  },
62
77
  ],
63
78
  }));
64
- server.registerResource("tasks", "pm://tasks", { description: "任务清单原始数据(tasks.json;超 100 条截断,完整数据用 list_tasks 过滤)" }, async (uri) => ({
79
+ server.registerResource("tasks", "pm://tasks", { description: "任务原始数据;大列表末尾返回 continuation,可通过 list_tasks 保持 include_done:true 连续取回后续任务" }, async (uri) => ({
65
80
  contents: [
66
81
  {
67
82
  uri: uri.href,
68
83
  mimeType: "application/json",
69
84
  text: await resourceText("tasks", () => {
70
- const tasks = loadTasks(root).tasks;
85
+ const data = loadTasks(root);
86
+ const tasks = data.tasks;
71
87
  // 资源也要守 token 预算:全量 dump 大账本会吃爆上下文(截断后仍是合法 JSON)
72
88
  if (tasks.length <= 100)
73
89
  return JSON.stringify(tasks, null, 2);
74
- const shown = tasks.slice(0, 100);
90
+ const page = taskPage(root, { include_done: true, page_size: 100 }, budgetLines(root) - 4, data);
91
+ const shown = tasks.slice(0, page.returned);
75
92
  shown.push({
76
- id: `_TRUNCATED_${tasks.length - 100}`,
77
- title: `…另有 ${tasks.length - 100} 个任务已截断,用 list_tasks 按状态/里程碑过滤获取`,
93
+ id: `_TRUNCATED_${tasks.length - page.returned}`,
94
+ title: `…本次输出已截断,另有 ${tasks.length - page.returned} 个任务,用 continuation 连续读取`,
95
+ continuation: { tool: "list_tasks", arguments: { include_done: true, cursor: page.next_cursor } },
78
96
  });
79
97
  return JSON.stringify(shown, null, 2);
80
98
  }),
@@ -145,12 +163,12 @@ server.registerPrompt("end-session", { description: "收工仪式:更新任务
145
163
  type: "text",
146
164
  text: [
147
165
  "收工前依次完成(顺序执行,缺一不可):",
148
- "1. 完成的任务:update_task 置 done,必须写 result_note(做了什么),feature/fix 类补 verification(用什么测试/命令证明)。",
166
+ "1. feature/fix 先执行适用测试,生成最新质量报告;update_task 置 done,给 result_note 与实际 files,record_session=true 可一并归档。",
149
167
  "2. 未完成的任务:checkpoint 保存「进展 + 下一步具体动作」。",
150
168
  "3. 本次落地的功能:register_feature 登记(入口文件 + 测试文件)。",
151
169
  "4. 修过的 bug:log_debug 记录症状/根因/修法/验证。",
152
170
  "5. 走过的捷径:add_task(type=debt) 登记债务,别让它在沉默中腐烂。",
153
- "6. log_session 记录会话摘要与改动文件清单(如实列出,波及面统计靠它)。",
171
+ "6. update_task 已返回自动会话编号时,不重复 log_session;否则用 log_session 记录真实改动清单。纯问答不需要另建任务或反复写会话。",
154
172
  "7. 若到达里程碑节点:update_milestone 流转状态;可 snapshot_codebase 拍快照 + audit_structure 对账。",
155
173
  ].join("\n"),
156
174
  },
@@ -216,6 +234,8 @@ if (isInitialized(root)) {
216
234
  ensurePmRuntimeIgnored(root);
217
235
  const watcher = startWatcher(root);
218
236
  console.error(`[pm-mcp] watcher coordinator: ${watcher ? "active(同项目单 leader)" : "unavailable(降级为按需全量走查)"}`);
237
+ logRuntime(root, watcher ? "info" : "warn", watcher ? "watcher.active" : "watcher.unavailable", watcher ? undefined : "降级为按需全量走查");
219
238
  }
239
+ logRuntime(root, "info", "server.ready", `pid=${process.pid} node=${process.versions.node}`);
220
240
  // stdio 是协议通道,日志只能走 stderr
221
241
  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
  }
@@ -5,9 +5,11 @@ import { foldLines, normSep } from "./budget.js";
5
5
  import { refreshDerived } from "./dashboard.js";
6
6
  import { decisionsDir, requireInitialized } from "./paths.js";
7
7
  import { formatCodeSearch, searchCode, searchKnowledge } from "./search.js";
8
- import { loadDebugLog, loadFeatures, loadFileNotes, loadProject, loadSessions, nextId, saveDebugLog, saveFeatures, saveFileNotes, saveSessions, } from "./store.js";
8
+ import { loadDebugLog, loadFeatures, loadFileNotes, loadProject, nextId, saveDebugLog, saveFeatures, saveFileNotes, } from "./store.js";
9
9
  import { budgetLines, toolR, toolW } from "./tool-base.js";
10
10
  import { now } from "./types.js";
11
+ import { appendSession } from "./session-log.js";
12
+ import { captureFileHashes } from "./git-state.js";
11
13
  export function registerFeatureTools(server, root) {
12
14
  toolW(server, root, "register_feature", "登记功能(回答「这个项目有哪些功能」)。entry_files=入口文件(漂移对账锚点),test_files=测试文件(测试背书)。", {
13
15
  name: z.string().min(1),
@@ -113,28 +115,17 @@ export function registerSessionTools(server, root) {
113
115
  files: z.array(z.string()).optional().describe("本次改动/新增/删除的文件(相对路径)"),
114
116
  next_steps: z.array(z.string()).optional().describe("留给下次的下一步"),
115
117
  author: z.string().optional().describe("署名:zcode / codex / human:名字"),
116
- }, (args) => {
118
+ }, (args, hashes) => {
117
119
  requireInitialized(root);
118
120
  const project = loadProject(root);
119
- const data = loadSessions(root);
120
- const id = nextId("S", data.seq, 4);
121
- data.seq += 1;
122
- data.sessions.push({
123
- id,
124
- date: now(),
125
- author: args.author ?? "",
126
- summary: args.summary,
127
- files: (args.files ?? []).map(normSep),
128
- next_steps: args.next_steps ?? [],
129
- });
130
- saveSessions(root, data);
121
+ const id = appendSession(root, args, hashes);
131
122
  refreshDerived(root);
132
123
  const L = [`✅ 会话 ${id} 已记录,changelog.md 已更新。`];
133
124
  if ((args.files ?? []).length > project.budgets.sessionBlastRadius) {
134
125
  L.push(`⚠️ 本次波及 ${(args.files ?? []).length} 个文件,超过阈值 ${project.budgets.sessionBlastRadius}——波及面过大,考虑拆任务或立重构任务。`);
135
126
  }
136
127
  return L.join("\n");
137
- });
128
+ }, (args) => { requireInitialized(root); return captureFileHashes(root, args.files ?? []); });
138
129
  toolW(server, root, "log_debug", "记录调试过程:症状 / 根因 / 修法 / 怎么验证的。调试知识账——下次同样的错不用从零猜,search_knowledge 可查。", {
139
130
  symptom: z.string().describe("症状:什么现象、报什么错"),
140
131
  root_cause: z.string().describe("根因(诊断出的真正原因,不是表面现象)"),