@luckychen1993/pm-mcp 0.1.5 → 0.4.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/tool-base.js CHANGED
@@ -25,19 +25,21 @@ export function recordUsage(root, tool, kind, started, outcome) {
25
25
  }
26
26
  }
27
27
  /** 只读工具:跨 Agent/进程合并完全相同的并行请求,短窗复用结果。 */
28
- export function toolR(server, root, name, description, schema, fn) {
29
- server.registerTool(name, { description, inputSchema: schema, annotations: { readOnlyHint: true } }, (async (args) => {
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) => {
30
30
  const started = Date.now();
31
31
  try {
32
- const result = await runCoalescedRead(root, name, args, fn);
32
+ const result = await runCoalescedRead(root, presentation ? `${name}:${presentation.cacheVersion ?? "structured-v1"}` : name, args, fn);
33
+ const decoded = presentation?.decode(result.text);
34
+ const text = decoded?.text ?? result.text;
33
35
  recordUsage(root, name, "read", started, {
34
36
  ok: !result.pending,
35
- text: result.text,
37
+ text: decoded ? text + JSON.stringify(decoded.data) : text,
36
38
  replayed: result.replayed,
37
39
  pending: result.pending,
38
40
  uncertain: result.uncertain,
39
41
  });
40
- return { content: [{ type: "text", text: result.text }] };
42
+ return { content: [{ type: "text", text }], ...(decoded ? { structuredContent: decoded.data } : {}) };
41
43
  }
42
44
  catch (error) {
43
45
  const message = error.message;
package/dist/types.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { normSep } from "./budget.js";
3
+ import { ScanIgnoreSchema } from "./scan-policy.js";
4
+ import { CompletionEvidenceSchema } from "./quality-evidence.js";
3
5
  export const now = () => new Date().toISOString();
4
6
  /** 文件清单字段:统一 / 分隔(Windows 反斜杠入库即规范化),缺省空数组 */
5
7
  const filesField = () => z.array(z.string()).default([]).transform((list) => list.map(normSep));
@@ -24,6 +26,8 @@ export const ProjectSchema = z.object({
24
26
  phase: z.string().default(""),
25
27
  /** 登记的模块名(用于复杂度账的"未登记目录"检测) */
26
28
  modules: z.array(z.string()).default([]),
29
+ scan_ignore: ScanIgnoreSchema.optional(),
30
+ completion_evidence: CompletionEvidenceSchema.optional(),
27
31
  /** 暴露面:local=本机工具 / network=内网服务 / public=公网服务,影响安全告警力度 */
28
32
  exposure: ExposureSchema.default("local"),
29
33
  /** 项目许可证(SPDX 或名称),用于依赖兼容性对账 */
@@ -118,6 +122,7 @@ export const SessionSchema = z.object({
118
122
  author: z.string().default(""),
119
123
  summary: z.string().trim().min(1),
120
124
  files: filesField(),
125
+ file_hashes: z.record(z.string().regex(/^[a-f0-9]{64}$/)).optional(),
121
126
  next_steps: z.array(z.string()).default([]),
122
127
  });
123
128
  export const SessionsFileSchema = z.object({
package/dist/ui.js ADDED
@@ -0,0 +1,191 @@
1
+ import http from "node:http";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { isInitialized } from "./paths.js";
5
+ import { loadProject, loadTasks, loadRoadmap, loadFeatures, loadSessions, loadSecurity } from "./store.js";
6
+ import { milestoneStats } from "./roadmap.js";
7
+ import { aggregates, ensureFresh, getIndex } from "./index-store.js";
8
+ import { VERSION } from "./version.js";
9
+ /**
10
+ * 本地只读 Web 仪表盘(零依赖 node:http):
11
+ * pm-mcp ui [--root <项目>] [--port <端口>]
12
+ *
13
+ * 安全边界:仅绑定 127.0.0.1、纯 GET 只读、未初始化 fail-closed、无外部资源、
14
+ * 所有插值经 HTML 转义(任务标题等用户内容不可注入);响应 CSP 禁止脚本。
15
+ */
16
+ const esc = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
17
+ function latestQualityRun(root) {
18
+ const dir = path.join(root, ".pm", "quality-runs");
19
+ try {
20
+ const latest = fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort().pop();
21
+ if (!latest)
22
+ return null;
23
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, latest), "utf8"));
24
+ const summary = raw.results?.[0]?.output_summary;
25
+ return { at: raw.run_at ?? "", ok: raw.ok === true, tests: summary ? `${summary.passed}/${summary.tests}` : "n/a" };
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function buildDashboardState(root) {
32
+ const absolute = path.resolve(root);
33
+ const project = loadProject(absolute);
34
+ const { tasks } = loadTasks(absolute);
35
+ const { milestones } = loadRoadmap(absolute);
36
+ const features = loadFeatures(absolute).features;
37
+ const security = loadSecurity(absolute).findings;
38
+ const sessions = loadSessions(absolute).sessions;
39
+ const byStatus = {};
40
+ for (const task of tasks)
41
+ byStatus[task.status] = (byStatus[task.status] ?? 0) + 1;
42
+ const recent = [...tasks]
43
+ .sort((a, b) => b.id.localeCompare(a.id))
44
+ .slice(0, 10)
45
+ .map((task) => ({ id: task.id, title: task.title, status: task.status, priority: task.priority ?? "" }));
46
+ let index = null;
47
+ try {
48
+ ensureFresh(absolute);
49
+ const agg = aggregates(getIndex(absolute));
50
+ index = { files: agg.totalFiles, loc: agg.totalLoc };
51
+ }
52
+ catch {
53
+ index = null;
54
+ }
55
+ const last = sessions.length > 0 ? sessions[sessions.length - 1] : null;
56
+ return {
57
+ name: project.name,
58
+ description: project.description,
59
+ phase: project.phase,
60
+ exposure: project.exposure,
61
+ version: VERSION,
62
+ milestones: milestones.map((milestone) => {
63
+ const stats = milestoneStats(tasks, milestone.id);
64
+ return { id: milestone.id, title: milestone.title, status: milestone.status, done: stats.done, total: stats.total, pct: stats.total === 0 ? 0 : stats.pct };
65
+ }),
66
+ tasks: { total: tasks.length, byStatus, recent },
67
+ features: {
68
+ total: features.length,
69
+ implemented: features.filter((f) => f.status === "implemented").length,
70
+ planned: features.filter((f) => f.status === "planned").length,
71
+ deprecated: features.filter((f) => f.status === "deprecated").length,
72
+ },
73
+ security: {
74
+ open: security.filter((f) => f.status === "open").length,
75
+ high: security.filter((f) => f.status === "open" && f.severity === "high").length,
76
+ accepted: security.filter((f) => f.status === "accepted").length,
77
+ },
78
+ sessions: {
79
+ count: sessions.length,
80
+ last: last ? { at: last.date, author: last.author, summary: last.summary } : null,
81
+ },
82
+ index,
83
+ quality: latestQualityRun(absolute),
84
+ };
85
+ }
86
+ export function renderDashboardHtml(state) {
87
+ const taskStatus = Object.entries(state.tasks.byStatus).map(([status, count]) => `${esc(status)} ${count}`).join(" · ") || "无";
88
+ const milestoneRows = state.milestones.map((m) => {
89
+ const statusIcon = m.status === "done" ? "✅" : m.status === "active" ? "▶" : "☐";
90
+ return `<tr><td>${esc(m.id)}</td><td>${statusIcon} ${esc(m.title)}</td><td>${m.done}/${m.total}</td>` +
91
+ `<td><div class="bar"><div class="fill" style="width:${Math.min(100, Math.max(0, m.pct))}%"></div></div>${m.pct}%</td></tr>`;
92
+ }).join("");
93
+ const taskRows = state.tasks.recent.map((task) => `<tr><td>${esc(task.id)}</td><td>${esc(task.title)}</td><td>${esc(task.status)}</td><td>${esc(task.priority)}</td></tr>`).join("");
94
+ const lastSession = state.sessions.last
95
+ ? `<p>最近:${esc(state.sessions.last.at)} · ${esc(state.sessions.last.author)} — ${esc(state.sessions.last.summary.slice(0, 160))}</p>`
96
+ : "<p>(无会话记录)</p>";
97
+ const quality = state.quality
98
+ ? `<span class="${state.quality.ok ? "ok" : "bad"}">${state.quality.ok ? "✅" : "🚩"} ${esc(state.quality.tests)}</span> @ ${esc(state.quality.at)}`
99
+ : "(暂无质量报告)";
100
+ return `<!doctype html>
101
+ <html lang="zh-CN"><head><meta charset="utf-8">
102
+ <meta name="viewport" content="width=device-width,initial-scale=1">
103
+ <title>${esc(state.name)} · pm-mcp</title>
104
+ <style>
105
+ body{font-family:system-ui,"Segoe UI",sans-serif;margin:0;background:#f6f7f9;color:#1c2733}
106
+ main{max-width:960px;margin:0 auto;padding:24px}
107
+ h1{font-size:22px}h2{font-size:16px;margin:28px 0 8px;border-bottom:1px solid #dde3ea;padding-bottom:6px}
108
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
109
+ .card{background:#fff;border:1px solid #dde3ea;border-radius:8px;padding:12px}
110
+ .card b{display:block;font-size:20px}.card span{color:#5b6b7c;font-size:12px}
111
+ table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #dde3ea}
112
+ td,th{padding:6px 10px;border-bottom:1px solid #eef1f4;text-align:left;font-size:13px}
113
+ .bar{background:#e8edf2;border-radius:6px;height:10px;min-width:80px;display:inline-block;vertical-align:middle;margin-right:6px}
114
+ .fill{background:#3d7bd9;height:10px;border-radius:6px}
115
+ .ok{color:#1a7f37}.bad{color:#c62828}
116
+ footer{color:#8a97a5;font-size:12px;margin-top:24px}
117
+ </style></head><body><main>
118
+ <h1>${esc(state.name)} <small style="font-size:13px;color:#5b6b7c">pm-mcp v${esc(state.version)}</small></h1>
119
+ <p>${esc(state.description || "(无描述)")} · 阶段:${esc(state.phase || "未设置")} · 暴露面:${esc(state.exposure)}</p>
120
+ <h2>概览</h2>
121
+ <div class="grid">
122
+ <div class="card"><b>${state.tasks.total}</b><span>任务</span></div>
123
+ <div class="card"><b>${state.features.implemented}</b><span>功能已实现 / 共 ${state.features.total}</span></div>
124
+ <div class="card"><b class="${state.security.high > 0 ? "bad" : "ok"}">${state.security.open}</b><span>安全未处理(高危 ${state.security.high})</span></div>
125
+ <div class="card"><b>${state.sessions.count}</b><span>会话记录</span></div>
126
+ <div class="card"><b>${state.index ? state.index.files : "—"}</b><span>索引文件${state.index ? ` / ${state.index.loc} 行` : ""}</span></div>
127
+ <div class="card"><b>${quality}</b><span>最近质量门禁</span></div>
128
+ </div>
129
+ <h2>里程碑</h2>
130
+ ${milestoneRows ? `<table><tr><th>ID</th><th>标题</th><th>任务</th><th>进度</th></tr>${milestoneRows}</table>` : "<p>(无里程碑)</p>"}
131
+ <h2>最近任务</h2>
132
+ ${taskRows ? `<table><tr><th>ID</th><th>标题</th><th>状态</th><th>优先级</th></tr>${taskRows}</table><p style="font-size:12px;color:#5b6b7c">状态分布:${esc(taskStatus)}</p>` : "<p>(无任务)</p>"}
133
+ <h2>会话</h2>
134
+ ${lastSession}
135
+ <footer>只读视图 · 仅绑定 127.0.0.1 · 数据来自 ${esc(state.name)} 的 .pm/ 台账 · ${new Date().toISOString()}</footer>
136
+ </main></body></html>`;
137
+ }
138
+ const HEAD_405 = { allow: "GET, HEAD" };
139
+ export async function startUiServer(root, options = {}) {
140
+ const absolute = path.resolve(root);
141
+ if (!isInitialized(absolute))
142
+ throw new Error(`目标不是已纳管项目(缺 .pm/):${absolute}`);
143
+ const server = http.createServer((request, response) => {
144
+ response.setHeader("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'");
145
+ response.setHeader("x-content-type-options", "nosniff");
146
+ response.setHeader("cache-control", "no-store");
147
+ try {
148
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
149
+ if (request.method !== "GET" && request.method !== "HEAD") {
150
+ response.writeHead(405, HEAD_405);
151
+ response.end("只读服务");
152
+ return;
153
+ }
154
+ if (url.pathname === "/healthz") {
155
+ response.writeHead(200, { "content-type": "text/plain" });
156
+ response.end("ok");
157
+ return;
158
+ }
159
+ if (url.pathname === "/api/state") {
160
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
161
+ response.end(JSON.stringify(buildDashboardState(absolute)));
162
+ return;
163
+ }
164
+ if (url.pathname === "/") {
165
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
166
+ response.end(renderDashboardHtml(buildDashboardState(absolute)));
167
+ return;
168
+ }
169
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
170
+ response.end("404");
171
+ }
172
+ catch (error) {
173
+ // fail-closed:不泄漏堆栈,只给一条简短原因
174
+ response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
175
+ response.end(`500: ${error.message.slice(0, 200)}`);
176
+ }
177
+ });
178
+ await new Promise((resolve, reject) => {
179
+ server.once("error", reject);
180
+ server.listen(options.port ?? 0, "127.0.0.1", resolve);
181
+ });
182
+ const address = server.address();
183
+ if (address === null || typeof address === "string") {
184
+ server.close();
185
+ throw new Error("UI 服务监听失败。");
186
+ }
187
+ return {
188
+ url: `http://127.0.0.1:${address.port}/`,
189
+ close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
190
+ };
191
+ }
@@ -0,0 +1,10 @@
1
+ import fs from "node:fs";
2
+ // src/ 与发布后的 dist/ 共用包根,避免服务端与安装器各自硬编码版本。
3
+ const metadata = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
4
+ export const SERVER_NAME = "pm-mcp";
5
+ export const VERSION = metadata.version;
6
+ export const PACKAGE_SPEC = `${metadata.name}@${VERSION}`;
7
+ export const RUNTIME_CONTRACT = "pagination-evidence-v1";
8
+ // 服务器工具目录契约(doctor 与独立巡检 health-check 都以此核对)。
9
+ // 新增/移除 MCP 工具时必须同步更新这里,并用 `pm-mcp probe` 实测复核。
10
+ export const TOOL_CATALOG_SIZE = 52;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckychen1993/pm-mcp",
3
- "version": "0.1.5",
3
+ "version": "0.4.0",
4
4
  "description": "AI 编码项目的单一事实来源 + 健康台账 MCP 服务(事实来源 / 路线图 / 防幻觉 / 七本账 / 断点续做 / token 经济)",
5
5
  "keywords": [
6
6
  "mcp",
@@ -32,12 +32,18 @@
32
32
  "engines": {
33
33
  "node": ">=22.18.0"
34
34
  },
35
+ "pm_mcp": {
36
+ "acceptance_baseline": {
37
+ "id": "pm-mcp-local-release",
38
+ "version": "1.1.0"
39
+ }
40
+ },
35
41
  "scripts": {
36
42
  "build": "tsc -p tsconfig.json",
37
43
  "prepare": "npm run build",
38
44
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.scripts.json",
39
- "test": "node --test \"test/*.test.ts\"",
40
- "coverage": "node --experimental-test-coverage --test-coverage-include=\"src/**/*.ts\" --test-coverage-lines=90 --test-coverage-branches=85 --test-coverage-functions=90 --test \"test/*.test.ts\"",
45
+ "test": "node --test-concurrency=2 --test \"test/*.test.ts\"",
46
+ "coverage": "node --experimental-test-coverage --test-coverage-include=\"src/**/*.ts\" --test-coverage-lines=90 --test-coverage-branches=85 --test-coverage-functions=90 --test-concurrency=2 --test \"test/*.test.ts\"",
41
47
  "audit": "node scripts/health-check.mts",
42
48
  "benchmark": "node scripts/benchmark.mts",
43
49
  "benchmark:volume": "node scripts/benchmark-volume.mts",
@@ -45,7 +51,7 @@
45
51
  "quality:plan": "node scripts/quality-gate.mts --plan",
46
52
  "acceptance:evidence": "node scripts/collect-acceptance-evidence.mts --root .",
47
53
  "acceptance:prepare": "node scripts/create-pm-acceptance-evaluation.mts --root .",
48
- "acceptance": "node scripts/acceptance-gate.mts --root . --baseline-id pm-mcp-local-release --baseline-version 1.0.0 --evaluation .pm/acceptance/evaluations/pm-mcp-local-release-1.0.0.json",
54
+ "acceptance": "node scripts/acceptance-cycle.mts --root .",
49
55
  "acceptance:cycle": "node scripts/acceptance-cycle.mts --root .",
50
56
  "gate": "npm run audit -- --root . && npm run quality && npm run acceptance:cycle",
51
57
  "start": "node dist/index.js",