@luckychen1993/pm-mcp 0.1.5 → 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/LICENSE +21 -21
- package/README.md +102 -10
- package/dist/agents-md.js +3 -3
- package/dist/audit.js +25 -21
- 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 +15 -8
- package/dist/knowledge-tools.js +6 -15
- package/dist/language-adapters.js +11 -10
- package/dist/license.js +28 -2
- package/dist/probe.js +141 -0
- package/dist/project-fingerprint.js +9 -5
- package/dist/project-tools.js +13 -2
- 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 +4 -0
- package/dist/security.js +2 -0
- 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 +92 -94
- 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 +7 -5
- package/dist/types.js +5 -0
- package/dist/version.js +10 -0
- package/package.json +7 -4
|
@@ -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
|
+
}
|
package/dist/governance-tools.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/index-store.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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]) ||
|
|
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
|
|
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
|
@@ -17,6 +17,9 @@ import { AcceptanceReportSchema } from "./acceptance-report.js";
|
|
|
17
17
|
import { runCoalescedRead } from "./idempotency.js";
|
|
18
18
|
import { recordUsage } from "./tool-base.js";
|
|
19
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";
|
|
20
23
|
/* --root 参数 > PM_ROOT 环境变量 > 启动时工作目录 */
|
|
21
24
|
const argv = process.argv.slice(2);
|
|
22
25
|
let explicitRoot;
|
|
@@ -25,8 +28,9 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
25
28
|
explicitRoot = argv[i + 1];
|
|
26
29
|
}
|
|
27
30
|
const root = resolveRoot(explicitRoot);
|
|
28
|
-
const server = new McpServer({ name: "pm-mcp", version:
|
|
31
|
+
const server = new McpServer({ name: "pm-mcp", version: VERSION }, {
|
|
29
32
|
instructions: [
|
|
33
|
+
`pm-mcp ${VERSION}。get_status 返回实际运行版本与项目根;配置修改后须重连核验。list_tasks 有 has_more 时保持过滤条件并使用 next_cursor 续取,不能把一页当全量。feature/fix 默认要求 tests_observed,未知计数不冒充测试通过。`,
|
|
30
34
|
"多 Agent 规则:相同业务的所有写工具调用必须携带相同 idempotency_key(建议 task-id:operation);同键同参数只执行一次,同键不同参数会被拒绝。",
|
|
31
35
|
"完全相同的并行读请求会由服务端合并并复用结果;不要让多个分支重复请求同一工具和参数。",
|
|
32
36
|
"看到“幂等复用”时直接采用首次结果;看到“幂等占位/正在执行”时等待负责该业务的 Agent,不要换键重试。",
|
|
@@ -72,20 +76,23 @@ server.registerResource("roadmap", "pm://roadmap", { description: "路线图(
|
|
|
72
76
|
},
|
|
73
77
|
],
|
|
74
78
|
}));
|
|
75
|
-
server.registerResource("tasks", "pm://tasks", { description: "
|
|
79
|
+
server.registerResource("tasks", "pm://tasks", { description: "任务原始数据;大列表末尾返回 continuation,可通过 list_tasks 保持 include_done:true 连续取回后续任务" }, async (uri) => ({
|
|
76
80
|
contents: [
|
|
77
81
|
{
|
|
78
82
|
uri: uri.href,
|
|
79
83
|
mimeType: "application/json",
|
|
80
84
|
text: await resourceText("tasks", () => {
|
|
81
|
-
const
|
|
85
|
+
const data = loadTasks(root);
|
|
86
|
+
const tasks = data.tasks;
|
|
82
87
|
// 资源也要守 token 预算:全量 dump 大账本会吃爆上下文(截断后仍是合法 JSON)
|
|
83
88
|
if (tasks.length <= 100)
|
|
84
89
|
return JSON.stringify(tasks, null, 2);
|
|
85
|
-
const
|
|
90
|
+
const page = taskPage(root, { include_done: true, page_size: 100 }, budgetLines(root) - 4, data);
|
|
91
|
+
const shown = tasks.slice(0, page.returned);
|
|
86
92
|
shown.push({
|
|
87
|
-
id: `_TRUNCATED_${tasks.length -
|
|
88
|
-
title:
|
|
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 } },
|
|
89
96
|
});
|
|
90
97
|
return JSON.stringify(shown, null, 2);
|
|
91
98
|
}),
|
|
@@ -156,12 +163,12 @@ server.registerPrompt("end-session", { description: "收工仪式:更新任务
|
|
|
156
163
|
type: "text",
|
|
157
164
|
text: [
|
|
158
165
|
"收工前依次完成(顺序执行,缺一不可):",
|
|
159
|
-
"1.
|
|
166
|
+
"1. feature/fix 先执行适用测试,生成最新质量报告;update_task 置 done,给 result_note 与实际 files,record_session=true 可一并归档。",
|
|
160
167
|
"2. 未完成的任务:checkpoint 保存「进展 + 下一步具体动作」。",
|
|
161
168
|
"3. 本次落地的功能:register_feature 登记(入口文件 + 测试文件)。",
|
|
162
169
|
"4. 修过的 bug:log_debug 记录症状/根因/修法/验证。",
|
|
163
170
|
"5. 走过的捷径:add_task(type=debt) 登记债务,别让它在沉默中腐烂。",
|
|
164
|
-
"6. log_session
|
|
171
|
+
"6. update_task 已返回自动会话编号时,不重复 log_session;否则用 log_session 记录真实改动清单。纯问答不需要另建任务或反复写会话。",
|
|
165
172
|
"7. 若到达里程碑节点:update_milestone 流转状态;可 snapshot_codebase 拍快照 + audit_structure 对账。",
|
|
166
173
|
].join("\n"),
|
|
167
174
|
},
|
package/dist/knowledge-tools.js
CHANGED
|
@@ -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,
|
|
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
|
|
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("根因(诊断出的真正原因,不是表面现象)"),
|
|
@@ -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("");
|