@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/README.md +119 -14
- package/dist/agents-md.js +51 -0
- package/dist/audit.js +25 -21
- package/dist/budget.js +36 -3
- package/dist/cli.js +32 -0
- package/dist/dashboard.js +6 -4
- package/dist/doctor.js +148 -0
- package/dist/git-state.js +75 -0
- package/dist/governance-tools.js +12 -2
- package/dist/index-db.js +40 -0
- package/dist/index-store.js +35 -44
- package/dist/index.js +29 -9
- package/dist/init.js +3 -0
- package/dist/knowledge-tools.js +6 -15
- package/dist/language-adapters.js +11 -10
- package/dist/license.js +28 -2
- package/dist/paths.js +1 -1
- package/dist/probe.js +141 -0
- package/dist/project-fingerprint.js +9 -5
- package/dist/project-tools.js +35 -3
- package/dist/quality-evidence.js +56 -0
- package/dist/quality-store.js +5 -25
- package/dist/registry.js +28 -2
- package/dist/scan-policy.js +34 -0
- package/dist/scan.js +5 -25
- package/dist/search.js +5 -1
- package/dist/security.js +66 -5
- package/dist/semantic-evidence-store.js +22 -0
- package/dist/semantic-parsers.js +18 -0
- package/dist/session-log.js +14 -0
- package/dist/setup-project.js +146 -0
- package/dist/setup.js +136 -18
- package/dist/task-evidence.js +110 -0
- package/dist/task-pagination.js +64 -0
- package/dist/task-tools.js +47 -37
- package/dist/tool-base.js +70 -9
- package/dist/types.js +5 -0
- package/dist/usage-log.js +278 -0
- package/dist/version.js +10 -0
- package/package.json +7 -4
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { SCAN_IGNORE_DIRS, ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
|
|
4
5
|
import { parseManifestDependencies } from "./language-dependencies.js";
|
|
5
|
-
const DEFAULT_IGNORE_DIRS =
|
|
6
|
-
|
|
7
|
-
"node_modules", "dist", "build", "out", "target", ".gradle", ".next", ".nuxt",
|
|
8
|
-
".output", ".turbo", "coverage", ".cache",
|
|
9
|
-
]);
|
|
10
|
-
const LANGUAGE_ORDER = [
|
|
11
|
-
"javascript", "typescript", "python", "go", "rust", "java", "kotlin", "csharp",
|
|
12
|
-
];
|
|
6
|
+
const DEFAULT_IGNORE_DIRS = SCAN_IGNORE_DIRS;
|
|
7
|
+
const LANGUAGE_ORDER = ["javascript", "typescript", "python", "go", "rust", "java", "kotlin", "csharp"];
|
|
13
8
|
const KIND_ORDER = ["test", "build", "lint", "typecheck", "coverage", "security"];
|
|
14
9
|
const DEFAULT_TIMEOUT_MS = 5 * 60_000;
|
|
15
|
-
|
|
10
|
+
// 计数汇总("# tests N")在输出末尾,只保留头部会把它一起丢掉 → 计数器解析为 null → 验收把实测值判成 -1。
|
|
11
|
+
const DEFAULT_OUTPUT_BYTES = 1024 * 1024;
|
|
16
12
|
function normalize(value) {
|
|
17
13
|
return value.replace(/\\/g, "/");
|
|
18
14
|
}
|
|
@@ -258,6 +254,7 @@ export function discoverProjectUnits(root, options = {}) {
|
|
|
258
254
|
if (!Number.isInteger(maxDepth) || maxDepth < 0)
|
|
259
255
|
throw new Error("maxDepth must be a non-negative integer");
|
|
260
256
|
const ignored = new Set([...DEFAULT_IGNORE_DIRS, ...(options.ignoreDirs ?? [])]);
|
|
257
|
+
const excluded = ignoreMatcher(projectScanIgnores(repositoryRoot));
|
|
261
258
|
const grouped = new Map();
|
|
262
259
|
const walk = (dir, depth) => {
|
|
263
260
|
if (depth > maxDepth)
|
|
@@ -265,6 +262,8 @@ export function discoverProjectUnits(root, options = {}) {
|
|
|
265
262
|
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
266
263
|
for (const entry of entries) {
|
|
267
264
|
const absolute = path.join(dir, entry.name);
|
|
265
|
+
if (excluded(normalize(path.relative(repositoryRoot, absolute)), entry.isDirectory()))
|
|
266
|
+
continue;
|
|
268
267
|
if (entry.isDirectory()) {
|
|
269
268
|
if (!ignored.has(entry.name))
|
|
270
269
|
walk(absolute, depth + 1);
|
|
@@ -403,7 +402,9 @@ async function runOne(item) {
|
|
|
403
402
|
// shell:false is intentional: arguments never undergo shell parsing.
|
|
404
403
|
let child;
|
|
405
404
|
try {
|
|
406
|
-
|
|
405
|
+
// Node 测试宿主的内部标记会让子 node --test 直接跳过文件并退出0,不能泄漏给质量命令。
|
|
406
|
+
const { NODE_TEST_CONTEXT: _nodeTestContext, ...env } = process.env;
|
|
407
|
+
child = spawn(item.command, item.args, { cwd: item.cwd, env, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
407
408
|
}
|
|
408
409
|
catch (error) {
|
|
409
410
|
const err = error;
|
package/dist/license.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { scanExclusionNote } from "./scan-policy.js";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { loadFileNotes, loadProject } from "./store.js";
|
|
4
5
|
import { isTestFile, readDirectDeps } from "./scan.js";
|
|
@@ -11,10 +12,11 @@ import { foldLines } from "./budget.js";
|
|
|
11
12
|
*/
|
|
12
13
|
const STRONG_COPYLEFT = ["GPL-2.0", "GPL-3.0", "AGPL-3.0", "SSPL-1.0", "GPL", "AGPL", "SSPL"];
|
|
13
14
|
const WEAK_COPYLEFT = ["LGPL", "MPL-2.0", "MPL", "EPL", "CDDL", "EUPL", "Sleepycat"];
|
|
14
|
-
const PERMISSIVE = ["MIT", "Apache-2.0", "Apache", "BSD-2-Clause", "BSD-3-Clause", "BSD", "ISC", "0BSD", "Unlicense", "Zlib", "Python-2.0", "BSL-1.0"];
|
|
15
|
+
const PERMISSIVE = ["MIT", "Apache-2.0", "Apache", "BSD-2-Clause", "BSD-3-Clause", "BSD", "ISC", "0BSD", "Unlicense", "Zlib", "Python-2.0", "BSL-1.0", "BlueOak-1.0.0"];
|
|
15
16
|
/** 单个包最多登记数(巨型依赖树保护;超出部分计数上报) */
|
|
16
17
|
const MAX_DEPS = 800;
|
|
17
|
-
|
|
18
|
+
/** 单个许可标识的判定(不含 SPDX 表达式拆解)。 */
|
|
19
|
+
function classifySingle(license) {
|
|
18
20
|
const l = license.trim();
|
|
19
21
|
if (!l)
|
|
20
22
|
return "unknown";
|
|
@@ -31,6 +33,27 @@ function classify(license) {
|
|
|
31
33
|
return "permissive";
|
|
32
34
|
return "unknown";
|
|
33
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* 判定依赖许可证。支持 SPDX 表达式:去掉外层括号后按 OR 拆开,逐项判定并取【最严格】的一项。
|
|
38
|
+
* 取最严格而非最宽松,是为了不削弱发布门禁:(MIT OR GPL-3.0) 仍判 strong-copyleft;
|
|
39
|
+
* 只有全部操作数都为宽松许可时(如 (MIT OR WTFPL))才不再落到 unknown。
|
|
40
|
+
*/
|
|
41
|
+
function classify(license) {
|
|
42
|
+
const stripped = license.trim().replace(/^\((.*)\)$/u, "$1").trim();
|
|
43
|
+
if (!stripped)
|
|
44
|
+
return "unknown";
|
|
45
|
+
if (/\s+OR\s+/iu.test(stripped)) {
|
|
46
|
+
const rank = { unknown: 0, permissive: 1, "weak-copyleft": 2, "strong-copyleft": 3 };
|
|
47
|
+
let worst = "unknown";
|
|
48
|
+
for (const part of stripped.split(/\s+OR\s+/iu)) {
|
|
49
|
+
const kind = classifySingle(part.replace(/^\((.*)\)$/u, "$1").trim());
|
|
50
|
+
if (rank[kind] > rank[worst])
|
|
51
|
+
worst = kind;
|
|
52
|
+
}
|
|
53
|
+
return worst;
|
|
54
|
+
}
|
|
55
|
+
return classifySingle(stripped);
|
|
56
|
+
}
|
|
34
57
|
function readPkgLicense(pkgFile) {
|
|
35
58
|
try {
|
|
36
59
|
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
@@ -96,6 +119,8 @@ function readNodeLicenses(root) {
|
|
|
96
119
|
if (!fs.existsSync(nested))
|
|
97
120
|
continue;
|
|
98
121
|
for (const entry of fs.readdirSync(nested, { withFileTypes: true })) {
|
|
122
|
+
if (entry.name.startsWith("."))
|
|
123
|
+
continue;
|
|
99
124
|
if (entry.name.startsWith("@") && isDirLike(entry)) {
|
|
100
125
|
for (const sub of fs.readdirSync(path.join(nested, entry.name), { withFileTypes: true })) {
|
|
101
126
|
if (isDirLike(sub))
|
|
@@ -147,6 +172,7 @@ export function auditLicense(root, maxLines = 150, forceFresh = false, indexPrep
|
|
|
147
172
|
const declared = project.license || pkgLicense;
|
|
148
173
|
const projectKind = classify(declared);
|
|
149
174
|
const L = [];
|
|
175
|
+
L.push(...scanExclusionNote(root));
|
|
150
176
|
L.push("## 许可证审计");
|
|
151
177
|
/* ① 项目声明 */
|
|
152
178
|
L.push("");
|
package/dist/paths.js
CHANGED
|
@@ -57,7 +57,7 @@ export function ensurePmRuntimeIgnored(root) {
|
|
|
57
57
|
fs.mkdirSync(directory, { recursive: true });
|
|
58
58
|
fs.mkdirSync(path.join(directory, ".runtime", "idempotency"), { recursive: true });
|
|
59
59
|
const file = path.join(directory, ".gitignore");
|
|
60
|
-
const required = [".lock", ".runtime/", "benchmarks/", "index.db*"];
|
|
60
|
+
const required = [".lock", ".runtime/", "benchmarks/", "index.db*", "usage-log*.jsonl*", "runtime-log*.jsonl*"];
|
|
61
61
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
62
62
|
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
63
63
|
const lines = current.split(/\r?\n/).map((line) => line.trim());
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
/**
|
|
7
|
+
* 标准 raw tools/list 直连探针。
|
|
8
|
+
*
|
|
9
|
+
* 宿主(ZCode/Codex/Cursor)是唯一 MCP client 持有者,不向模型透传原始
|
|
10
|
+
* `tools/list`;本探针绕过宿主,自己以 MCP client 身份直连服务器进程,
|
|
11
|
+
* 拿协议线上的原始工具清单,并可与期望表比对(如 Codex allowlist)。
|
|
12
|
+
*/
|
|
13
|
+
export const PROBE_USAGE = "pm-mcp probe [--timeout <毫秒>] [--expect <逗号分隔工具名> | --expect-file <json>] -- <启动 MCP 服务器的命令...>";
|
|
14
|
+
export function parseProbeArgs(argv) {
|
|
15
|
+
const separator = argv.indexOf("--");
|
|
16
|
+
const optionArgs = separator >= 0 ? argv.slice(0, separator) : argv;
|
|
17
|
+
const serverArgs = separator >= 0 ? argv.slice(separator + 1) : [];
|
|
18
|
+
const options = { command: "", args: [], timeoutMs: 30_000 };
|
|
19
|
+
let expectSeen = 0;
|
|
20
|
+
for (let index = 0; index < optionArgs.length; index++) {
|
|
21
|
+
const arg = optionArgs[index], value = optionArgs[index + 1];
|
|
22
|
+
if (arg === "--timeout") {
|
|
23
|
+
const parsed = Number(value);
|
|
24
|
+
if (!value || !Number.isFinite(parsed) || parsed <= 0)
|
|
25
|
+
throw new Error(`probe 参数无效: ${arg} ${value ?? "(缺值)"}`);
|
|
26
|
+
options.timeoutMs = parsed;
|
|
27
|
+
index++;
|
|
28
|
+
}
|
|
29
|
+
else if (arg === "--expect" || arg === "--expect-file") {
|
|
30
|
+
if (!value)
|
|
31
|
+
throw new Error(`probe 参数无效: ${arg} (缺值)`);
|
|
32
|
+
if (++expectSeen > 1)
|
|
33
|
+
throw new Error("--expect 与 --expect-file 只能用一个。");
|
|
34
|
+
options.expect = arg === "--expect" ? parseExpectList(value) : readExpectFile(value);
|
|
35
|
+
index++;
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
throw new Error(`probe 参数无效: ${arg}。用法:${PROBE_USAGE}`);
|
|
39
|
+
}
|
|
40
|
+
if (separator < 0 || serverArgs.length === 0)
|
|
41
|
+
throw new Error(`缺少 "-- <服务器启动命令>"。用法:${PROBE_USAGE}`);
|
|
42
|
+
if (serverArgs.includes("--"))
|
|
43
|
+
throw new Error("服务器启动命令内部不能再出现 -- 分隔符。");
|
|
44
|
+
options.command = serverArgs[0];
|
|
45
|
+
options.args = serverArgs.slice(1);
|
|
46
|
+
return options;
|
|
47
|
+
}
|
|
48
|
+
function parseExpectList(value) {
|
|
49
|
+
const names = [...new Set(value.split(",").map((name) => name.trim()).filter(Boolean))];
|
|
50
|
+
if (names.length === 0)
|
|
51
|
+
throw new Error("--expect 至少需要一个工具名。");
|
|
52
|
+
return names;
|
|
53
|
+
}
|
|
54
|
+
/** 期望表接受:字符串数组、上一轮 probe 报告、或 {tools:[名字|{name}]}。 */
|
|
55
|
+
function readExpectFile(file) {
|
|
56
|
+
const stat = fs.lstatSync(file);
|
|
57
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024)
|
|
58
|
+
throw new Error("expect 文件必须为有界普通文件,禁止链接。");
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new Error(`expect 文件不是合法 JSON:${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
const list = Array.isArray(parsed) ? parsed
|
|
67
|
+
: Array.isArray(parsed?.tools) ? parsed.tools
|
|
68
|
+
: undefined;
|
|
69
|
+
if (!list)
|
|
70
|
+
throw new Error("expect 文件格式不识别:应为字符串数组、probe 报告或 {tools:[...]}。");
|
|
71
|
+
const names = [...new Set(list.map((entry) => typeof entry === "string" ? entry : entry?.name)
|
|
72
|
+
.filter((name) => typeof name === "string" && name.length > 0))];
|
|
73
|
+
if (names.length === 0)
|
|
74
|
+
throw new Error("expect 文件里没有可识别的工具名。");
|
|
75
|
+
return names;
|
|
76
|
+
}
|
|
77
|
+
export async function runProbe(options) {
|
|
78
|
+
const timeoutMs = options.timeoutMs;
|
|
79
|
+
const report = { ok: false, command: options.command, args: options.args, toolCount: 0, tools: [], issues: [] };
|
|
80
|
+
const client = new Client({ name: "pm-mcp-probe", version: VERSION });
|
|
81
|
+
// env 为覆盖式合并(SDK 先取默认安全白名单再覆盖),供 PM_MCP_HOME 之类的沙箱重定向。
|
|
82
|
+
const transport = new StdioClientTransport({ command: options.command, args: options.args, stderr: "pipe", env: options.env });
|
|
83
|
+
try {
|
|
84
|
+
await client.connect(transport, { timeout: timeoutMs });
|
|
85
|
+
transport.stderr?.on("data", () => undefined);
|
|
86
|
+
const info = client.getServerVersion();
|
|
87
|
+
if (info)
|
|
88
|
+
report.serverInfo = { name: info.name, version: info.version };
|
|
89
|
+
const tools = [];
|
|
90
|
+
let cursor;
|
|
91
|
+
do {
|
|
92
|
+
const page = await client.listTools(cursor ? { cursor } : {}, { timeout: timeoutMs });
|
|
93
|
+
for (const tool of page.tools)
|
|
94
|
+
tools.push({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema });
|
|
95
|
+
cursor = page.nextCursor;
|
|
96
|
+
} while (cursor);
|
|
97
|
+
report.tools = tools;
|
|
98
|
+
report.toolCount = tools.length;
|
|
99
|
+
if (tools.length === 0)
|
|
100
|
+
report.issues.push("服务器未暴露任何工具。");
|
|
101
|
+
if (options.expect) {
|
|
102
|
+
const actual = new Set(tools.map((tool) => tool.name));
|
|
103
|
+
const expected = [...new Set(options.expect)];
|
|
104
|
+
const missing = expected.filter((name) => !actual.has(name));
|
|
105
|
+
const extra = tools.map((tool) => tool.name).filter((name) => !expected.includes(name));
|
|
106
|
+
report.expect = { expected, missing, extra };
|
|
107
|
+
if (missing.length > 0)
|
|
108
|
+
report.issues.push(`服务器未暴露 ${missing.length} 个期望工具: ${missing.join(", ")}`);
|
|
109
|
+
if (extra.length > 0)
|
|
110
|
+
report.issues.push(`服务器暴露了 ${extra.length} 个期望之外的工具: ${extra.join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
report.ok = report.issues.length === 0;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
report.issues.push(`raw tools/list 探测失败: ${error.message}`);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await client.close().catch(() => undefined);
|
|
119
|
+
hardKill(transport);
|
|
120
|
+
}
|
|
121
|
+
return report;
|
|
122
|
+
}
|
|
123
|
+
/** client.close() 正常会终止子进程;这里兜底清理仍存活的残留,避免夹具/服务器挂死。 */
|
|
124
|
+
function hardKill(transport) {
|
|
125
|
+
const pid = transport.pid;
|
|
126
|
+
if (!pid)
|
|
127
|
+
return;
|
|
128
|
+
try {
|
|
129
|
+
process.kill(pid, 0);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
if (process.platform === "win32")
|
|
136
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
|
|
137
|
+
else
|
|
138
|
+
process.kill(pid, "SIGKILL");
|
|
139
|
+
}
|
|
140
|
+
catch { /* 尽力而为 */ }
|
|
141
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
]);
|
|
8
|
-
function hashRegularFile(file) {
|
|
4
|
+
import { SCAN_IGNORE_DIRS, ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
|
|
5
|
+
const DEFAULT_IGNORED_DIRECTORIES = SCAN_IGNORE_DIRS;
|
|
6
|
+
export function hashRegularFile(file) {
|
|
9
7
|
const handle = fs.openSync(file, "r");
|
|
10
8
|
const digest = createHash("sha256");
|
|
11
9
|
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
@@ -43,6 +41,10 @@ export function fingerprintProject(root) {
|
|
|
43
41
|
throw new Error(`项目根必须是普通目录: ${absolute}`);
|
|
44
42
|
const rootReal = fs.realpathSync.native(absolute);
|
|
45
43
|
const tree = createHash("sha256");
|
|
44
|
+
const ignores = projectScanIgnores(root);
|
|
45
|
+
const excluded = ignoreMatcher(ignores);
|
|
46
|
+
if (ignores.length)
|
|
47
|
+
tree.update(`scan-ignore\0${JSON.stringify(ignores)}\0`);
|
|
46
48
|
let files = 0;
|
|
47
49
|
let bytes = 0;
|
|
48
50
|
const visit = (directory, relativeDirectory) => {
|
|
@@ -52,6 +54,8 @@ export function fingerprintProject(root) {
|
|
|
52
54
|
continue;
|
|
53
55
|
const absoluteEntry = path.join(directory, entry.name);
|
|
54
56
|
const relative = path.posix.join(relativeDirectory.replace(/\\/g, "/"), entry.name);
|
|
57
|
+
if (excluded(relative, entry.isDirectory()))
|
|
58
|
+
continue;
|
|
55
59
|
if (relative === "PROJECT.md")
|
|
56
60
|
continue; // generated dashboard; authoritative inputs live in .pm and source files
|
|
57
61
|
const stat = fs.lstatSync(absoluteEntry);
|
package/dist/project-tools.js
CHANGED
|
@@ -8,6 +8,10 @@ import { loadGovernance } from "./governance-model.js";
|
|
|
8
8
|
import { loadProject, loadRoadmap, loadSecurity, loadSessions, loadTasks, saveProject, saveRoadmap, } from "./store.js";
|
|
9
9
|
import { now } from "./types.js";
|
|
10
10
|
import { budgetLines, toolI, toolR, toolW } from "./tool-base.js";
|
|
11
|
+
import { renderRuntimeLogLines, renderUsageLogLines } from "./usage-log.js";
|
|
12
|
+
import { ScanIgnoreSchema } from "./scan-policy.js";
|
|
13
|
+
import { VERSION, RUNTIME_CONTRACT } from "./version.js";
|
|
14
|
+
import { CompletionEvidenceSchema } from "./quality-evidence.js";
|
|
11
15
|
function taskLine(t, withNext = false) {
|
|
12
16
|
const parts = [`[${t.status}]`, t.id, t.title, `(${t.type}${t.milestone ? `,${t.milestone}` : ""})`];
|
|
13
17
|
if (withNext && t.checkpoint) {
|
|
@@ -16,7 +20,7 @@ function taskLine(t, withNext = false) {
|
|
|
16
20
|
return parts.join(" ");
|
|
17
21
|
}
|
|
18
22
|
export function registerProjectTools(server, root) {
|
|
19
|
-
toolW(server, root, "init_project", "初始化当前项目的 .pm/
|
|
23
|
+
toolW(server, root, "init_project", "初始化当前项目的 .pm/ 状态目录(单一事实来源),并自动写入 AGENTS.md 工作规矩(已存在则增量合并,不覆盖你的内容)。每个新项目只调用一次;已初始化请用 update_project。", {
|
|
20
24
|
name: z.string().min(1).describe("项目名"),
|
|
21
25
|
description: z.string().optional().describe("一句话描述"),
|
|
22
26
|
stack: z.array(z.string()).optional().describe("技术栈,如 [\"TypeScript\",\"React\"]"),
|
|
@@ -24,13 +28,15 @@ export function registerProjectTools(server, root) {
|
|
|
24
28
|
license: z.string().optional().describe("项目许可证(SPDX 名,如 MIT)"),
|
|
25
29
|
exposure: z.enum(["local", "network", "public"]).optional().describe("暴露面,影响安全告警力度"),
|
|
26
30
|
modules: z.array(z.string()).optional().describe("初始模块登记"),
|
|
31
|
+
agents_md: z.boolean().optional().describe("默认 true:自动写入/合并 AGENTS.md 工作规矩(false 关闭)"),
|
|
27
32
|
}, (args) => {
|
|
28
|
-
const project = initProject(root, args);
|
|
33
|
+
const project = initProject(root, { ...args, agentsMd: args.agents_md });
|
|
29
34
|
refreshDerived(root);
|
|
30
35
|
return [
|
|
31
36
|
`✅ 项目已初始化: ${project.name}`,
|
|
32
37
|
`状态目录: ${pmPath(root, "")}(随 git 提交,团队/AI 共享)`,
|
|
33
38
|
"仪表盘已生成: PROJECT.md",
|
|
39
|
+
args.agents_md === false ? "AGENTS.md 规矩:按要求跳过(agents_md:false)" : "AGENTS.md 工作规矩已写入(已存在则合并;ZCode 每次打开该项目自动加载)",
|
|
34
40
|
"建议顺序: upsert_module 声明模块/owner/语言 → add_milestone 建里程碑 → add_task 拆任务 → 开工前 get_status / 收工后 log_session。",
|
|
35
41
|
].join("\n");
|
|
36
42
|
});
|
|
@@ -43,6 +49,7 @@ export function registerProjectTools(server, root) {
|
|
|
43
49
|
const security = loadSecurity(root);
|
|
44
50
|
const L = [];
|
|
45
51
|
L.push(`# ${project.name}${project.phase ? ` — ${project.phase}` : ""}`);
|
|
52
|
+
L.push(`运行版本: pm-mcp ${VERSION} · 项目根: ${root}`);
|
|
46
53
|
if (project.description)
|
|
47
54
|
L.push(project.description);
|
|
48
55
|
L.push("");
|
|
@@ -93,7 +100,26 @@ export function registerProjectTools(server, root) {
|
|
|
93
100
|
}
|
|
94
101
|
L.push("");
|
|
95
102
|
L.push("> 三段式定位:本状态 → search_code 精确到行 → 才读具体文件。别全量读代码。");
|
|
96
|
-
return foldLines(L, { maxLines: budgetLines(root) });
|
|
103
|
+
return JSON.stringify({ text: foldLines(L, { maxLines: budgetLines(root) }), runtime: { version: VERSION, root, contract: RUNTIME_CONTRACT } });
|
|
104
|
+
}, { cacheVersion: `runtime:${VERSION}`, outputSchema: { runtime: z.object({ version: z.string(), root: z.string(), contract: z.string() }) },
|
|
105
|
+
decode: (value) => { const result = JSON.parse(value); return { text: result.text, data: { runtime: result.runtime } }; } });
|
|
106
|
+
toolR(server, root, "get_usage_log", "使用日志:pm-mcp 被用了哪些工具、成败、耗时,以及输出 token 估算与「折叠省下的 token」——回答“用它到底省不省 token”。只记工具名与计量,不记参数内容。日志滚动保留约 2MB。", {
|
|
107
|
+
last: z.number().int().min(1).max(200).optional().describe("返回最近 N 条明细(默认 20;汇总始终覆盖全部保留日志)"),
|
|
108
|
+
tool: z.string().optional().describe("只看某工具,如 get_status"),
|
|
109
|
+
ok: z.boolean().optional().describe("true 只看成,false 只看败"),
|
|
110
|
+
since: z.string().optional().describe("ISO 日期/时间,只看该时间之后的明细"),
|
|
111
|
+
}, (args) => {
|
|
112
|
+
requireInitialized(root);
|
|
113
|
+
return foldLines(renderUsageLogLines(root, args), { maxLines: budgetLines(root) });
|
|
114
|
+
});
|
|
115
|
+
toolR(server, root, "get_runtime_log", "运行日志:server 就绪、watcher 状态、工具报错等服务级事件(info/warn/error),判断 pm-mcp 运行是否健康、哪里在反复出错。", {
|
|
116
|
+
last: z.number().int().min(1).max(200).optional().describe("返回最近 N 条明细(默认 20)"),
|
|
117
|
+
level: z.enum(["info", "warn", "error"]).optional().describe("只看某级别"),
|
|
118
|
+
event: z.string().optional().describe("只看某事件,如 tool.error / server.ready"),
|
|
119
|
+
since: z.string().optional().describe("ISO 日期/时间,只看该时间之后的明细"),
|
|
120
|
+
}, (args) => {
|
|
121
|
+
requireInitialized(root);
|
|
122
|
+
return foldLines(renderRuntimeLogLines(root, args), { maxLines: budgetLines(root) });
|
|
97
123
|
});
|
|
98
124
|
toolW(server, root, "update_project", "更新项目元信息/预算(描述、技术栈、目标、阶段、模块登记、许可证、暴露面、复杂度与输出预算)。", {
|
|
99
125
|
description: z.string().optional(),
|
|
@@ -103,6 +129,8 @@ export function registerProjectTools(server, root) {
|
|
|
103
129
|
license: z.string().optional(),
|
|
104
130
|
exposure: z.enum(["local", "network", "public"]).optional(),
|
|
105
131
|
modules: z.array(z.string()).optional().describe("模块登记(用于未登记目录检测)"),
|
|
132
|
+
scan_ignore: ScanIgnoreSchema.optional().describe("本项目产物/缓存的相对排除 glob,例如 release/**;传 [] 清除;不应排除真实源码"),
|
|
133
|
+
completion_evidence: CompletionEvidenceSchema.optional().describe("默认要求tests_observed;兼容无计数运行器时显式选择execution_only并记录原因"),
|
|
106
134
|
max_file_loc: z.number().int().min(10).optional().describe("单文件行数预算"),
|
|
107
135
|
refactor_quota_pct: z.number().min(0).max(100).optional().describe("里程碑重构类任务最低占比%"),
|
|
108
136
|
session_blast_radius: z.number().int().min(1).optional().describe("单会话波及文件阈值"),
|
|
@@ -124,6 +152,10 @@ export function registerProjectTools(server, root) {
|
|
|
124
152
|
project.exposure = args.exposure;
|
|
125
153
|
if (args.modules !== undefined)
|
|
126
154
|
project.modules = args.modules;
|
|
155
|
+
if (args.scan_ignore !== undefined)
|
|
156
|
+
project.scan_ignore = args.scan_ignore;
|
|
157
|
+
if (args.completion_evidence !== undefined)
|
|
158
|
+
project.completion_evidence = CompletionEvidenceSchema.parse(args.completion_evidence);
|
|
127
159
|
if (args.max_file_loc !== undefined)
|
|
128
160
|
project.budgets.maxFileLoc = args.max_file_loc;
|
|
129
161
|
if (args.refactor_quota_pct !== undefined)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const CompletionEvidenceSchema = z.object({
|
|
4
|
+
minimum: z.enum(["tests_observed", "execution_only"]),
|
|
5
|
+
reason: z.string().trim().min(8).max(1000),
|
|
6
|
+
});
|
|
7
|
+
const ranks = { unverified: 0, execution_only: 1, tests_observed: 2, coverage_observed: 3 };
|
|
8
|
+
/** 等级描述观测强度,不代表测试充分、结果通过或来源不可伪造。 */
|
|
9
|
+
export function evidenceLevel(kind, summary, truncated = false) {
|
|
10
|
+
if (truncated || !["test", "coverage"].includes(kind))
|
|
11
|
+
return "execution_only";
|
|
12
|
+
const counters = [summary.failed, summary.skipped, summary.cancelled, summary.todo];
|
|
13
|
+
if (summary.tests === null || !Number.isSafeInteger(summary.tests) || summary.tests <= 0 ||
|
|
14
|
+
counters.some((n) => n === null || !Number.isSafeInteger(n) || n < 0) ||
|
|
15
|
+
counters.reduce((total, n) => total + (n ?? 0), 0) > summary.tests)
|
|
16
|
+
return "execution_only";
|
|
17
|
+
if (summary.passed != null && summary.passed + counters.reduce((total, n) => total + (n ?? 0), 0) !== summary.tests)
|
|
18
|
+
return "execution_only";
|
|
19
|
+
const coverage = z.object({ lines_pct: z.number().min(0).max(100), branches_pct: z.number().min(0).max(100), functions_pct: z.number().min(0).max(100) }).safeParse(summary.coverage);
|
|
20
|
+
return coverage.success ? "coverage_observed" : "tests_observed";
|
|
21
|
+
}
|
|
22
|
+
export function weakestEvidence(levels) {
|
|
23
|
+
return levels.length ? levels.reduce((a, b) => ranks[a] < ranks[b] ? a : b) : "execution_only";
|
|
24
|
+
}
|
|
25
|
+
export function evidenceLabel(level) {
|
|
26
|
+
return { unverified: "无可用验证证据", execution_only: "仅执行记录,未确认测试数量", tests_observed: "已观察测试结果", coverage_observed: "已观察测试与覆盖率" }[level ?? ""] ?? "旧报告未标等级";
|
|
27
|
+
}
|
|
28
|
+
export function qualityResultSummary(item) {
|
|
29
|
+
const summary = qualityOutputSummary(item.command.kind, item.stdout, item.stderr, item.truncated);
|
|
30
|
+
return { ...summary, evidence_level: item.exitCode === null ? "unverified" : summary.evidence_level };
|
|
31
|
+
}
|
|
32
|
+
export function qualityOutputSummary(kind, stdout, stderr, truncated = false) {
|
|
33
|
+
const output = `${stdout}\n${stderr}`.replace(/\u001b\[[0-9;]*m/g, "");
|
|
34
|
+
const diagnostic = (label) => {
|
|
35
|
+
const matches = [...output.matchAll(new RegExp(`^[ \\t]*(?:ℹ|#)?[ \\t]*${label}[ \\t]+(\\d+)[ \\t]*\\r?$`, "gmi"))];
|
|
36
|
+
return matches.length ? Number(matches.at(-1)[1]) : null;
|
|
37
|
+
};
|
|
38
|
+
let counts = { tests: diagnostic("tests"), passed: diagnostic("pass"), failed: diagnostic("fail"), cancelled: diagnostic("cancelled"), skipped: diagnostic("skipped"), todo: diagnostic("todo") };
|
|
39
|
+
let counterSource = counts.tests === null ? "unknown" : "node_test_summary";
|
|
40
|
+
const jest = [...output.matchAll(/^\s*Tests:\s*(.+?)(\d+)\s+total\s*$/gm)].at(-1);
|
|
41
|
+
const vitest = [...output.matchAll(/^\s*Tests\s+(.+?)\s*\((\d+)\)\s*$/gm)].at(-1);
|
|
42
|
+
const pytest = [...output.matchAll(/^=+\s+((?:\d+\s+(?:passed|failed|skipped|xfailed|xpassed|deselected|errors?|warnings?)[,\s]*)+)\s+in\s+[\d.]+s.*?=+\s*$/gm)].at(-1);
|
|
43
|
+
if (counts.tests === null && (jest || vitest || pytest)) {
|
|
44
|
+
const text = (jest ?? vitest ?? pytest)[1];
|
|
45
|
+
const number = (label) => Number(new RegExp(`(\\d+)\\s+${label}\\b`).exec(text)?.[1] ?? 0);
|
|
46
|
+
const passed = number("passed"), failed = number("failed") + number("errors?") + number("xpassed");
|
|
47
|
+
const skipped = number("skipped") + number("xfailed") + number("deselected"), todo = number("todo");
|
|
48
|
+
counts = { tests: jest ? Number(jest[2]) : vitest ? Number(vitest[2]) : passed + failed + skipped + todo, passed, failed, skipped, todo, cancelled: 0 };
|
|
49
|
+
counterSource = jest ? "jest_summary" : vitest ? "vitest_summary" : "pytest_summary";
|
|
50
|
+
}
|
|
51
|
+
const coverage = output.match(/(?:^|\n)(?:ℹ|#)?\s*all files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)(?:\s*\|\s*([\d.]+))?/i);
|
|
52
|
+
counts.coverage = coverage ? { lines_pct: Number(coverage[4] ?? coverage[1]), branches_pct: Number(coverage[2]), functions_pct: Number(coverage[3]) } : null;
|
|
53
|
+
return { stdout_bytes: Buffer.byteLength(stdout), stderr_bytes: Buffer.byteLength(stderr),
|
|
54
|
+
output_sha256: createHash("sha256").update(stdout).update("\0").update(stderr).digest("hex"),
|
|
55
|
+
...counts, counter_source: counterSource, evidence_level: evidenceLevel(kind, counts, truncated) };
|
|
56
|
+
}
|
package/dist/quality-store.js
CHANGED
|
@@ -1,48 +1,28 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
3
|
import { atomicWrite, withLedgerLock } from "./store.js";
|
|
5
4
|
import { refreshDerived } from "./dashboard.js";
|
|
6
|
-
|
|
7
|
-
const match = output.match(new RegExp(`(?:^|\\n)(?:ℹ|#)?\\s*${label}\\s+(\\d+)(?:\\s|$)`, "i"));
|
|
8
|
-
return match ? Number(match[1]) : null;
|
|
9
|
-
}
|
|
10
|
-
function outputSummary(stdout, stderr) {
|
|
11
|
-
const combined = `${stdout}\n${stderr}`;
|
|
12
|
-
// Node's test reporter exposes line|branch|function, while Istanbul/c8 uses
|
|
13
|
-
// statement|branch|function|line. Preserve one normalized three-metric shape.
|
|
14
|
-
const coverage = combined.match(/(?:^|\n)(?:ℹ|#)?\s*all files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)(?:\s*\|\s*([\d.]+))?/i);
|
|
15
|
-
return {
|
|
16
|
-
stdout_bytes: Buffer.byteLength(stdout),
|
|
17
|
-
stderr_bytes: Buffer.byteLength(stderr),
|
|
18
|
-
output_sha256: createHash("sha256").update(stdout, "utf8").update("\0", "utf8").update(stderr, "utf8").digest("hex"),
|
|
19
|
-
tests: numericDiagnostic(combined, "tests"),
|
|
20
|
-
passed: numericDiagnostic(combined, "pass"),
|
|
21
|
-
failed: numericDiagnostic(combined, "fail"),
|
|
22
|
-
cancelled: numericDiagnostic(combined, "cancelled"),
|
|
23
|
-
skipped: numericDiagnostic(combined, "skipped"),
|
|
24
|
-
todo: numericDiagnostic(combined, "todo"),
|
|
25
|
-
coverage: coverage ? { lines_pct: Number(coverage[4] ?? coverage[1]), branches_pct: Number(coverage[2]), functions_pct: Number(coverage[3]) } : null,
|
|
26
|
-
};
|
|
27
|
-
}
|
|
5
|
+
import { qualityResultSummary, weakestEvidence } from "./quality-evidence.js";
|
|
28
6
|
/** Persist a secret-minimised quality summary; raw stdout/stderr never enter the ledger. */
|
|
29
7
|
export function saveQualityRun(root, result, provenance) {
|
|
30
8
|
const runAt = new Date();
|
|
31
9
|
const stamp = runAt.toISOString().replace(/[-:]/g, "").replace("T", "-").replace(".", "-").replace(/Z$/, "");
|
|
32
10
|
const effectiveOk = result.ok && result.execute && result.results.length > 0 && result.results.every((item) => item.status === "passed" && item.exitCode === 0 && item.signal === null);
|
|
11
|
+
const summaries = result.results.map(qualityResultSummary);
|
|
33
12
|
const payload = JSON.stringify({
|
|
34
13
|
schema_version: 2,
|
|
35
14
|
run_at: runAt.toISOString(),
|
|
36
15
|
ok: effectiveOk,
|
|
37
16
|
reported_ok: result.ok,
|
|
38
17
|
execute: result.execute,
|
|
18
|
+
evidence_level: result.execute && result.results.some((item) => item.exitCode !== null) ? weakestEvidence(summaries.filter((_item, i) => ["test", "coverage"].includes(result.results[i].command.kind)).map((item) => item.evidence_level)) : "unverified",
|
|
39
19
|
environment: { platform: process.platform, arch: process.arch, node: process.version },
|
|
40
20
|
source: provenance ? {
|
|
41
21
|
before: provenance.source_before,
|
|
42
22
|
after: provenance.source_after,
|
|
43
23
|
stable: provenance.source_before.sha256 === provenance.source_after.sha256,
|
|
44
24
|
} : null,
|
|
45
|
-
results: result.results.map((item) => ({
|
|
25
|
+
results: result.results.map((item, index) => ({
|
|
46
26
|
unit_root: item.command.cwd,
|
|
47
27
|
kind: item.command.kind,
|
|
48
28
|
command: item.command.command,
|
|
@@ -52,7 +32,7 @@ export function saveQualityRun(root, result, provenance) {
|
|
|
52
32
|
signal: item.signal,
|
|
53
33
|
duration_ms: item.durationMs,
|
|
54
34
|
output_truncated: item.truncated,
|
|
55
|
-
output_summary:
|
|
35
|
+
output_summary: summaries[index],
|
|
56
36
|
error: item.error ?? "",
|
|
57
37
|
})),
|
|
58
38
|
}, null, 2) + "\n";
|
package/dist/registry.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { RegistrySchema, now } from "./types.js";
|
|
4
5
|
import { registryFile } from "./paths.js";
|
|
@@ -23,9 +24,18 @@ export function saveRegistry(data) {
|
|
|
23
24
|
}
|
|
24
25
|
/** 登记/刷新一个项目(init 与每次写操作都会 touch) */
|
|
25
26
|
export function touchRegistry(root, name) {
|
|
26
|
-
const data = loadRegistry();
|
|
27
27
|
const abs = path.resolve(root);
|
|
28
|
-
|
|
28
|
+
// 防污染(T-034 根因):临时目录里的"项目"从不进入真实注册表——
|
|
29
|
+
// 测试若未设 PM_MCP_HOME 沙箱,写入会被这里拦下而不是污染 ~/.pm-mcp。
|
|
30
|
+
// 沙箱化测试(显式设了 PM_MCP_HOME)不受影响,仍写沙箱注册表。
|
|
31
|
+
if (!process.env.PM_MCP_HOME?.trim() && isUnderTemp(abs)) {
|
|
32
|
+
console.error(`[pm-mcp] 跳过注册临时目录项目(如需管理请移出临时目录): ${abs}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const data = loadRegistry();
|
|
36
|
+
// Windows 路径大小写不敏感:按小写比较去重(e:/E: 重复注册缺陷)
|
|
37
|
+
const key = abs.toLowerCase();
|
|
38
|
+
const idx = data.projects.findIndex((p) => path.resolve(p.root).toLowerCase() === key);
|
|
29
39
|
const entry = { name, root: abs, last_seen: now() };
|
|
30
40
|
if (idx >= 0)
|
|
31
41
|
data.projects[idx] = entry;
|
|
@@ -33,6 +43,22 @@ export function touchRegistry(root, name) {
|
|
|
33
43
|
data.projects.push(entry);
|
|
34
44
|
saveRegistry(data);
|
|
35
45
|
}
|
|
46
|
+
function isUnderTemp(abs) {
|
|
47
|
+
const normalized = abs.toLowerCase();
|
|
48
|
+
// tmpdir 可能以 8.3 短名返回(ADMINI~1),与注册表里的长名不一致——两种形态都比对
|
|
49
|
+
let tmpLong = os.tmpdir().toLowerCase();
|
|
50
|
+
try {
|
|
51
|
+
tmpLong = fs.realpathSync.native(os.tmpdir()).toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* keep default */
|
|
55
|
+
}
|
|
56
|
+
for (const tmp of new Set([tmpLong, os.tmpdir().toLowerCase()])) {
|
|
57
|
+
if (normalized === tmp || normalized.startsWith(tmp.replace(/\$/, "") + path.sep))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
36
62
|
export function listRegistry() {
|
|
37
63
|
return loadRegistry().projects.sort((a, b) => b.last_seen.localeCompare(a.last_seen));
|
|
38
64
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { globToRegExp, normSep } from "./budget.js";
|
|
5
|
+
export const SCAN_IGNORE_DIRS = new Set([
|
|
6
|
+
"node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".output", ".turbo", "coverage",
|
|
7
|
+
"__pycache__", ".venv", "venv", "env", ".pm", ".idea", ".vscode", "target", ".cache", ".gradle",
|
|
8
|
+
".zcode", ".runtime", "EBWebView",
|
|
9
|
+
// 包管理器缓存 / 运行时数据 / 临时与上下文目录:属于环境而非项目源码(T-045)
|
|
10
|
+
".pnpm-cache", ".pnpm-store", ".npm-cache", ".yarn-cache", ".data", ".tmp", ".temp",
|
|
11
|
+
".codex-context", "userdata", "logs",
|
|
12
|
+
]);
|
|
13
|
+
export const ScanIgnoreSchema = z.array(z.string().trim().min(1).max(512).transform(normSep).refine((glob) => !glob.startsWith("/") && !glob.startsWith("!") && !/^[a-z]:/i.test(glob) && !glob.split("/").includes(".."), "扫描排除必须是项目内的正向相对 glob")).max(100);
|
|
14
|
+
export function projectScanIgnores(root) {
|
|
15
|
+
const file = path.join(root, ".pm", "project.json");
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = fs.readFileSync(file, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === "ENOENT")
|
|
22
|
+
return [];
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return [...new Set(ScanIgnoreSchema.parse(JSON.parse(raw).scan_ignore ?? []))].sort();
|
|
26
|
+
}
|
|
27
|
+
export function ignoreMatcher(globs) {
|
|
28
|
+
const patterns = globs.map(globToRegExp);
|
|
29
|
+
return (rel, directory = false) => patterns.some((pattern) => pattern.test(rel) || (directory && pattern.test(`${rel}/`)));
|
|
30
|
+
}
|
|
31
|
+
export function scanExclusionNote(root) {
|
|
32
|
+
const ignores = projectScanIgnores(root);
|
|
33
|
+
return ignores.length ? [`扫描范围排除:${ignores.join(", ")};排除内容不属于本次检查结论。`] : [];
|
|
34
|
+
}
|