@luckychen1993/pm-mcp 0.1.1

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +304 -0
  3. package/dist/acceptance-evaluator.js +330 -0
  4. package/dist/acceptance-model.js +440 -0
  5. package/dist/acceptance-report.js +335 -0
  6. package/dist/acceptance-tools.js +318 -0
  7. package/dist/audit-tools.js +61 -0
  8. package/dist/audit.js +364 -0
  9. package/dist/budget.js +71 -0
  10. package/dist/dashboard.js +241 -0
  11. package/dist/governance-audit.js +126 -0
  12. package/dist/governance-model.js +306 -0
  13. package/dist/governance-tools.js +240 -0
  14. package/dist/health.js +58 -0
  15. package/dist/index-db.js +97 -0
  16. package/dist/index-store.js +381 -0
  17. package/dist/index.js +210 -0
  18. package/dist/init.js +40 -0
  19. package/dist/knowledge-tools.js +204 -0
  20. package/dist/language-adapters.js +450 -0
  21. package/dist/language-dependencies.js +351 -0
  22. package/dist/license.js +261 -0
  23. package/dist/paths.js +51 -0
  24. package/dist/polyglot-ast.js +329 -0
  25. package/dist/portfolio.js +349 -0
  26. package/dist/project-fingerprint.js +83 -0
  27. package/dist/project-tools.js +217 -0
  28. package/dist/quality-store.js +77 -0
  29. package/dist/registry.js +38 -0
  30. package/dist/roadmap.js +96 -0
  31. package/dist/scan.js +271 -0
  32. package/dist/search.js +312 -0
  33. package/dist/security.js +269 -0
  34. package/dist/semantic-evidence-store.js +133 -0
  35. package/dist/semantic-evidence.js +100 -0
  36. package/dist/semantic-graph-algorithms.js +86 -0
  37. package/dist/semantic-graph.js +390 -0
  38. package/dist/semantic-parsers.js +311 -0
  39. package/dist/store.js +185 -0
  40. package/dist/task-tools.js +198 -0
  41. package/dist/tool-base.js +32 -0
  42. package/dist/tools.js +21 -0
  43. package/dist/types.js +208 -0
  44. package/dist/typescript-semantic.js +282 -0
  45. package/package.json +70 -0
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ const DEFAULT_IGNORED_DIRECTORIES = new Set([
5
+ ".git", ".pm", ".cache", ".gradle", ".idea", ".next", ".nuxt", ".output", ".turbo", ".venv", ".vscode", ".zcode",
6
+ "__pycache__", "build", "coverage", "dist", "env", "node_modules", "out", "target", "venv",
7
+ ]);
8
+ function hashRegularFile(file) {
9
+ const handle = fs.openSync(file, "r");
10
+ const digest = createHash("sha256");
11
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
12
+ try {
13
+ const before = fs.fstatSync(handle);
14
+ if (!before.isFile())
15
+ throw new Error(`项目指纹只接受普通文件: ${file}`);
16
+ let offset = 0;
17
+ while (offset < before.size) {
18
+ const read = fs.readSync(handle, buffer, 0, Math.min(buffer.length, before.size - offset), offset);
19
+ if (read === 0)
20
+ break;
21
+ digest.update(buffer.subarray(0, read));
22
+ offset += read;
23
+ }
24
+ const after = fs.fstatSync(handle);
25
+ if (offset !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
26
+ throw new Error(`生成项目指纹时文件发生变化: ${file}`);
27
+ }
28
+ return { sha256: digest.digest("hex"), bytes: before.size };
29
+ }
30
+ finally {
31
+ fs.closeSync(handle);
32
+ }
33
+ }
34
+ /**
35
+ * Deterministic content fingerprint for source acceptance when no trusted VCS
36
+ * revision exists. Directory entries are sorted, symlinks are rejected, and
37
+ * generated/cache directories are excluded by an explicit stable policy.
38
+ */
39
+ export function fingerprintProject(root) {
40
+ const absolute = path.resolve(root);
41
+ const rootStat = fs.lstatSync(absolute);
42
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
43
+ throw new Error(`项目根必须是普通目录: ${absolute}`);
44
+ const rootReal = fs.realpathSync.native(absolute);
45
+ const tree = createHash("sha256");
46
+ let files = 0;
47
+ let bytes = 0;
48
+ const visit = (directory, relativeDirectory) => {
49
+ const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"));
50
+ for (const entry of entries) {
51
+ if (entry.isDirectory() && DEFAULT_IGNORED_DIRECTORIES.has(entry.name))
52
+ continue;
53
+ const absoluteEntry = path.join(directory, entry.name);
54
+ const relative = path.posix.join(relativeDirectory.replace(/\\/g, "/"), entry.name);
55
+ if (relative === "PROJECT.md")
56
+ continue; // generated dashboard; authoritative inputs live in .pm and source files
57
+ const stat = fs.lstatSync(absoluteEntry);
58
+ if (stat.isSymbolicLink())
59
+ throw new Error(`项目指纹拒绝符号链接: ${relative}`);
60
+ if (stat.isDirectory()) {
61
+ const real = fs.realpathSync.native(absoluteEntry);
62
+ const escaped = path.relative(rootReal, real);
63
+ if (escaped === ".." || escaped.startsWith(`..${path.sep}`) || path.isAbsolute(escaped))
64
+ throw new Error(`目录逃逸项目根: ${relative}`);
65
+ visit(absoluteEntry, relative);
66
+ continue;
67
+ }
68
+ if (!stat.isFile())
69
+ throw new Error(`项目指纹拒绝非普通文件: ${relative}`);
70
+ const file = hashRegularFile(absoluteEntry);
71
+ tree.update(relative, "utf8");
72
+ tree.update("\0", "utf8");
73
+ tree.update(String(file.bytes), "utf8");
74
+ tree.update("\0", "utf8");
75
+ tree.update(file.sha256, "ascii");
76
+ tree.update("\n", "utf8");
77
+ files += 1;
78
+ bytes += file.bytes;
79
+ }
80
+ };
81
+ visit(absolute, "");
82
+ return { algorithm: "sha256-tree-v1", sha256: tree.digest("hex"), files, bytes };
83
+ }
@@ -0,0 +1,217 @@
1
+ import { z } from "zod";
2
+ import { initProject } from "./init.js";
3
+ import { pmPath, requireInitialized } from "./paths.js";
4
+ import { refreshDerived } from "./dashboard.js";
5
+ import { renderRoadmap, milestoneStats, checkpointSuffix } from "./roadmap.js";
6
+ import { foldLines } from "./budget.js";
7
+ import { loadGovernance } from "./governance-model.js";
8
+ import { loadProject, loadRoadmap, loadSecurity, loadSessions, loadTasks, saveProject, saveRoadmap, } from "./store.js";
9
+ import { now } from "./types.js";
10
+ import { budgetLines, tool, toolW } from "./tool-base.js";
11
+ function taskLine(t, withNext = false) {
12
+ const parts = [`[${t.status}]`, t.id, t.title, `(${t.type}${t.milestone ? `,${t.milestone}` : ""})`];
13
+ if (withNext && t.checkpoint) {
14
+ parts.push(`→ 下一步: ${t.checkpoint.next_step}${checkpointSuffix(t.checkpoint)}`);
15
+ }
16
+ return parts.join(" ");
17
+ }
18
+ export function registerProjectTools(server, root) {
19
+ toolW(server, root, "init_project", "初始化当前项目的 .pm/ 状态目录(单一事实来源)。每个新项目只调用一次;已初始化请用 update_project。", {
20
+ name: z.string().min(1).describe("项目名"),
21
+ description: z.string().optional().describe("一句话描述"),
22
+ stack: z.array(z.string()).optional().describe("技术栈,如 [\"TypeScript\",\"React\"]"),
23
+ goals: z.array(z.string()).optional().describe("项目目标列表"),
24
+ license: z.string().optional().describe("项目许可证(SPDX 名,如 MIT)"),
25
+ exposure: z.enum(["local", "network", "public"]).optional().describe("暴露面,影响安全告警力度"),
26
+ modules: z.array(z.string()).optional().describe("初始模块登记"),
27
+ }, (args) => {
28
+ const project = initProject(root, args);
29
+ refreshDerived(root);
30
+ return [
31
+ `✅ 项目已初始化: ${project.name}`,
32
+ `状态目录: ${pmPath(root, "")}(随 git 提交,团队/AI 共享)`,
33
+ "仪表盘已生成: PROJECT.md",
34
+ "建议顺序: upsert_module 声明模块/owner/语言 → add_milestone 建里程碑 → add_task 拆任务 → 开工前 get_status / 收工后 log_session。",
35
+ ].join("\n");
36
+ });
37
+ tool(server, "get_status", "一站式「我在哪」:项目阶段 + 里程碑进度 + 进行中任务(含断点下一步)+ 健康摘要 + 最近变更。会话开工第一件事。since 可只看某日期以来的任务/会话变化。", { since: z.string().optional().describe("ISO 日期,如 2026-09-01:只看该日期以来的任务/会话变化") }, (args) => {
38
+ requireInitialized(root);
39
+ const project = loadProject(root);
40
+ const { milestones } = loadRoadmap(root);
41
+ const { tasks } = loadTasks(root);
42
+ const { sessions } = loadSessions(root);
43
+ const security = loadSecurity(root);
44
+ const L = [];
45
+ L.push(`# ${project.name}${project.phase ? ` — ${project.phase}` : ""}`);
46
+ if (project.description)
47
+ L.push(project.description);
48
+ L.push("");
49
+ L.push("## 里程碑");
50
+ L.push(...renderRoadmap(milestones, tasks, 1));
51
+ L.push("");
52
+ L.push("## 当前焦点(断点恢复点)");
53
+ const active = tasks.filter((t) => t.status === "in_progress" || t.status === "blocked");
54
+ if (active.length === 0)
55
+ L.push("(无进行中任务——从 backlog 挑一个,或 add_task。)");
56
+ for (const t of active.slice(0, 10)) {
57
+ L.push(`- ${taskLine(t, true)}`);
58
+ if (t.steps.length > 0) {
59
+ const doneN = t.steps.filter((s) => s.done).length;
60
+ L.push(` 步骤 ${doneN}/${t.steps.length}: ${t.steps.filter((s) => !s.done)[0]?.text ?? "全部完成"}`);
61
+ }
62
+ }
63
+ L.push("");
64
+ L.push("## 健康摘要");
65
+ const openDebt = tasks.filter((t) => t.type === "debt" && t.status !== "done" && t.status !== "cancelled").length;
66
+ const openFindings = security.findings.filter((f) => f.status === "open");
67
+ L.push(`债务未清 ${openDebt} 条 · 安全未处理 ${openFindings.length} 个(高危 ${openFindings.filter((f) => f.severity === "high").length})· 会话 ${sessions.length} 次。详细对账用 audit_structure / audit_security。`);
68
+ try {
69
+ const governance = loadGovernance(root);
70
+ L.push(`治理模型 ${governance.modules.length} 模块 / ${governance.interfaces.length} 接口 / ${governance.repositories.length} 仓库;实时语义门禁用 audit_governance。`);
71
+ }
72
+ catch {
73
+ L.push("⚠️ 治理模型未初始化;跨模块/语言边界尚未启用。");
74
+ }
75
+ const last = sessions[sessions.length - 1];
76
+ if (last) {
77
+ L.push("");
78
+ L.push("## 最近会话");
79
+ L.push(`${last.date.slice(0, 10)} [${last.author || "?"}] ${last.summary}`);
80
+ if (last.next_steps.length > 0)
81
+ L.push(`上次留下的下一步: ${last.next_steps.join(";")}`);
82
+ }
83
+ if (args.since) {
84
+ const parsed = Date.parse(args.since);
85
+ const since = Number.isNaN(parsed) ? args.since : new Date(parsed).toISOString();
86
+ const newTasks = tasks.filter((t) => t.updated >= since);
87
+ const newSessions = sessions.filter((s) => s.date >= since);
88
+ L.push("");
89
+ L.push(`## 自 ${since.slice(0, 10)} 以来`);
90
+ L.push(`新增/更新任务 ${newTasks.length} 个,会话 ${newSessions.length} 次。`);
91
+ for (const t of newTasks.slice(-8).reverse())
92
+ L.push(`- ${taskLine(t)}`);
93
+ }
94
+ L.push("");
95
+ L.push("> 三段式定位:本状态 → search_code 精确到行 → 才读具体文件。别全量读代码。");
96
+ return foldLines(L, { maxLines: budgetLines(root) });
97
+ });
98
+ toolW(server, root, "update_project", "更新项目元信息/预算(描述、技术栈、目标、阶段、模块登记、许可证、暴露面、复杂度与输出预算)。", {
99
+ description: z.string().optional(),
100
+ stack: z.array(z.string()).optional(),
101
+ goals: z.array(z.string()).optional(),
102
+ phase: z.string().optional().describe("当前阶段,如 MVP 开发中"),
103
+ license: z.string().optional(),
104
+ exposure: z.enum(["local", "network", "public"]).optional(),
105
+ modules: z.array(z.string()).optional().describe("模块登记(用于未登记目录检测)"),
106
+ max_file_loc: z.number().int().min(10).optional().describe("单文件行数预算"),
107
+ refactor_quota_pct: z.number().min(0).max(100).optional().describe("里程碑重构类任务最低占比%"),
108
+ session_blast_radius: z.number().int().min(1).optional().describe("单会话波及文件阈值"),
109
+ output_budget_lines: z.number().int().min(20).optional().describe("读类工具输出行数预算"),
110
+ }, (args) => {
111
+ requireInitialized(root);
112
+ const project = loadProject(root);
113
+ if (args.description !== undefined)
114
+ project.description = args.description;
115
+ if (args.stack !== undefined)
116
+ project.stack = args.stack;
117
+ if (args.goals !== undefined)
118
+ project.goals = args.goals;
119
+ if (args.phase !== undefined)
120
+ project.phase = args.phase;
121
+ if (args.license !== undefined)
122
+ project.license = args.license;
123
+ if (args.exposure !== undefined)
124
+ project.exposure = args.exposure;
125
+ if (args.modules !== undefined)
126
+ project.modules = args.modules;
127
+ if (args.max_file_loc !== undefined)
128
+ project.budgets.maxFileLoc = args.max_file_loc;
129
+ if (args.refactor_quota_pct !== undefined)
130
+ project.budgets.refactorQuotaPct = args.refactor_quota_pct;
131
+ if (args.session_blast_radius !== undefined)
132
+ project.budgets.sessionBlastRadius = args.session_blast_radius;
133
+ if (args.output_budget_lines !== undefined)
134
+ project.budgets.outputBudgetLines = args.output_budget_lines;
135
+ saveProject(root, project);
136
+ refreshDerived(root);
137
+ return `✅ 项目信息已更新(阶段: ${project.phase || "—"},模块: ${project.modules.join(", ") || "—"})。仪表盘已刷新。`;
138
+ });
139
+ tool(server, "regenerate_dashboard", "手动重新生成 PROJECT.md 仪表盘与 changelog.md(正常情况下每次写操作已自动刷新)。", {}, () => {
140
+ requireInitialized(root);
141
+ refreshDerived(root);
142
+ return "✅ PROJECT.md 与 changelog.md 已重新生成。";
143
+ });
144
+ }
145
+ export function registerRoadmapTools(server, root) {
146
+ toolW(server, root, "add_milestone", "添加里程碑(M1/M2…,按顺序自动编号)。路线图的骨架;任务挂到里程碑上。", { title: z.string().min(1), goal: z.string().optional().describe("这个里程碑要达成什么") }, (args) => {
147
+ requireInitialized(root);
148
+ const data = loadRoadmap(root);
149
+ const id = `M${data.seq + 1}`;
150
+ const t = now();
151
+ data.seq += 1;
152
+ data.milestones.push({
153
+ id,
154
+ title: args.title,
155
+ goal: args.goal ?? "",
156
+ status: "planned",
157
+ order: data.milestones.length + 1,
158
+ created: t,
159
+ updated: t,
160
+ });
161
+ saveRoadmap(root, data);
162
+ refreshDerived(root);
163
+ return `✅ 里程碑 ${id}「${args.title}」已创建(planned)。开始推进时用 update_milestone 置为 active。`;
164
+ });
165
+ toolW(server, root, "update_milestone", "更新里程碑(状态流转 planned/active/done/paused、标题、目标、顺序)。", {
166
+ id: z.string().describe("里程碑 id,如 M1"),
167
+ status: z.enum(["planned", "active", "done", "paused"]).optional(),
168
+ title: z.string().optional(),
169
+ goal: z.string().optional(),
170
+ order: z.number().int().optional(),
171
+ }, (args) => {
172
+ requireInitialized(root);
173
+ const data = loadRoadmap(root);
174
+ const m = data.milestones.find((x) => x.id === args.id);
175
+ if (!m)
176
+ throw new Error(`找不到里程碑 ${args.id}。现有: ${data.milestones.map((x) => x.id).join(", ") || "无"}`);
177
+ if (args.status !== undefined)
178
+ m.status = args.status;
179
+ if (args.title !== undefined)
180
+ m.title = args.title;
181
+ if (args.goal !== undefined)
182
+ m.goal = args.goal;
183
+ if (args.order !== undefined)
184
+ m.order = args.order;
185
+ m.updated = now();
186
+ const warnings = [];
187
+ if (m.status === "done") {
188
+ const skipped = data.milestones.filter((x) => x.id !== m.id && x.order < m.order && (x.status === "active" || x.status === "planned"));
189
+ if (skipped.length > 0) {
190
+ warnings.push(`⚠️ 跳过前置里程碑:${skipped.map((x) => x.id).join(", ")} 尚未完成就被置后完成——确认是有意为之。`);
191
+ }
192
+ }
193
+ saveRoadmap(root, data);
194
+ refreshDerived(root);
195
+ const { tasks } = loadTasks(root);
196
+ const s = milestoneStats(tasks, m.id);
197
+ return [`✅ ${m.id} 已更新(${m.status})。进度: ${s.done}/${s.total}(${s.pct}%)。`, ...warnings].join("\n");
198
+ });
199
+ tool(server, "get_roadmap", "查看路线图。depth=1 每个里程碑一行摘要(功能再多也不乱);depth=2 额外展开活跃里程碑的任务明细与断点。", { depth: z.number().int().min(1).max(2).optional().describe("默认 1") }, (args) => {
200
+ requireInitialized(root);
201
+ const { milestones } = loadRoadmap(root);
202
+ const { tasks } = loadTasks(root);
203
+ const project = loadProject(root);
204
+ const L = ["# 路线图", ""];
205
+ L.push(...renderRoadmap(milestones, tasks, args.depth ?? 1));
206
+ const warns = milestones
207
+ .map((m) => ({ m, s: milestoneStats(tasks, m.id) }))
208
+ .filter(({ m, s }) => m.status !== "done" && s.total >= 5 && s.refactorPct < project.budgets.refactorQuotaPct);
209
+ if (warns.length > 0) {
210
+ L.push("");
211
+ for (const { m, s } of warns) {
212
+ L.push(`⚠️ 重构被挤出: ${m.id}(${s.total} 个任务中重构类仅 ${s.refactorPct}%,配额 ${project.budgets.refactorQuotaPct}%)——建议立 refactor/debt 任务。`);
213
+ }
214
+ }
215
+ return foldLines(L, { maxLines: budgetLines(root) });
216
+ });
217
+ }
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { atomicWrite, withLedgerLock } from "./store.js";
5
+ import { refreshDerived } from "./dashboard.js";
6
+ function numericDiagnostic(output, label) {
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
+ }
28
+ /** Persist a secret-minimised quality summary; raw stdout/stderr never enter the ledger. */
29
+ export function saveQualityRun(root, result, provenance) {
30
+ const runAt = new Date();
31
+ const stamp = runAt.toISOString().replace(/[-:]/g, "").replace("T", "-").replace(".", "-").replace(/Z$/, "");
32
+ const effectiveOk = result.ok && result.execute && result.results.length > 0 && result.results.every((item) => item.status === "passed" && item.exitCode === 0 && item.signal === null);
33
+ const payload = JSON.stringify({
34
+ schema_version: 2,
35
+ run_at: runAt.toISOString(),
36
+ ok: effectiveOk,
37
+ reported_ok: result.ok,
38
+ execute: result.execute,
39
+ environment: { platform: process.platform, arch: process.arch, node: process.version },
40
+ source: provenance ? {
41
+ before: provenance.source_before,
42
+ after: provenance.source_after,
43
+ stable: provenance.source_before.sha256 === provenance.source_after.sha256,
44
+ } : null,
45
+ results: result.results.map((item) => ({
46
+ unit_root: item.command.cwd,
47
+ kind: item.command.kind,
48
+ command: item.command.command,
49
+ args: item.command.args,
50
+ status: item.status,
51
+ exit_code: item.exitCode,
52
+ signal: item.signal,
53
+ duration_ms: item.durationMs,
54
+ output_truncated: item.truncated,
55
+ output_summary: outputSummary(item.stdout, item.stderr),
56
+ error: item.error ?? "",
57
+ })),
58
+ }, null, 2) + "\n";
59
+ const absoluteRoot = path.resolve(root);
60
+ const relative = withLedgerLock(absoluteRoot, () => {
61
+ const directory = path.join(absoluteRoot, ".pm", "quality-runs");
62
+ fs.mkdirSync(directory, { recursive: true });
63
+ for (let sequence = 0; sequence < 10_000; sequence += 1) {
64
+ const name = `quality-${stamp}-${String(sequence).padStart(4, "0")}.json`;
65
+ const file = path.join(directory, name);
66
+ if (fs.existsSync(file))
67
+ continue;
68
+ atomicWrite(file, payload);
69
+ return `.pm/quality-runs/${name}`;
70
+ }
71
+ throw new Error(`同一毫秒内质量证据数量超过上限,拒绝覆盖: ${stamp}`);
72
+ });
73
+ // Quality evidence is user-visible project state. Keep PROJECT.md in sync so
74
+ // a failed run cannot remain hidden behind the previous green dashboard.
75
+ refreshDerived(absoluteRoot);
76
+ return relative;
77
+ }
@@ -0,0 +1,38 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { RegistrySchema, now } from "./types.js";
4
+ import { registryFile } from "./paths.js";
5
+ import { atomicWrite } from "./store.js";
6
+ export function loadRegistry() {
7
+ const file = registryFile();
8
+ if (!fs.existsSync(file))
9
+ return { projects: [] };
10
+ try {
11
+ return RegistrySchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
12
+ }
13
+ catch (e) {
14
+ // 注册表损坏不应拖垮任何工具,告警后按空表处理
15
+ console.error(`[pm-mcp] 全局注册表读取失败,按空表处理: ${e.message}`);
16
+ return { projects: [] };
17
+ }
18
+ }
19
+ export function saveRegistry(data) {
20
+ const file = registryFile();
21
+ fs.mkdirSync(path.dirname(file), { recursive: true });
22
+ atomicWrite(file, JSON.stringify(data, null, 2) + "\n");
23
+ }
24
+ /** 登记/刷新一个项目(init 与每次写操作都会 touch) */
25
+ export function touchRegistry(root, name) {
26
+ const data = loadRegistry();
27
+ const abs = path.resolve(root);
28
+ const idx = data.projects.findIndex((p) => path.resolve(p.root) === abs);
29
+ const entry = { name, root: abs, last_seen: now() };
30
+ if (idx >= 0)
31
+ data.projects[idx] = entry;
32
+ else
33
+ data.projects.push(entry);
34
+ saveRegistry(data);
35
+ }
36
+ export function listRegistry() {
37
+ return loadRegistry().projects.sort((a, b) => b.last_seen.localeCompare(a.last_seen));
38
+ }
@@ -0,0 +1,96 @@
1
+ import { ageDays } from "./health.js";
2
+ /** 断点展示后缀:超过 7 天的旧断点标注可能过期 */
3
+ export function checkpointSuffix(cp) {
4
+ const days = ageDays(cp.at);
5
+ return days > 7 ? `(${days} 天前的断点,可能过期——建议刷新 checkpoint)` : "";
6
+ }
7
+ /** 进度条:10 格 */
8
+ export function bar(pct) {
9
+ const filled = Math.round((Math.max(0, Math.min(100, pct)) / 100) * 10);
10
+ return "[" + "█".repeat(filled) + "░".repeat(10 - filled) + "]";
11
+ }
12
+ export function milestoneStats(tasks, milestoneId) {
13
+ const own = tasks.filter((t) => t.milestone === milestoneId && t.status !== "cancelled");
14
+ const done = own.filter((t) => t.status === "done");
15
+ const refactorish = own.filter((t) => t.type === "refactor" || t.type === "debt");
16
+ return {
17
+ total: own.length,
18
+ done: done.length,
19
+ pct: own.length === 0 ? 0 : Math.round((done.length / own.length) * 100),
20
+ refactorish: refactorish.length,
21
+ refactorPct: own.length === 0 ? 0 : Math.round((refactorish.length / own.length) * 100),
22
+ };
23
+ }
24
+ const STATUS_ICON = {
25
+ done: "✅",
26
+ active: "▶",
27
+ paused: "⏸",
28
+ planned: "○",
29
+ };
30
+ /**
31
+ * 分层渲染路线图。
32
+ * depth=1:每个里程碑一行摘要(功能再多也不乱);
33
+ * depth=2:另展开活跃里程碑的任务明细(进行中优先,含断点 next_step)。
34
+ */
35
+ export function renderRoadmap(milestones, tasks, depth = 1, maxTasksPerMilestone = 10) {
36
+ const lines = [];
37
+ const sorted = [...milestones].sort((a, b) => a.order - b.order);
38
+ if (sorted.length === 0) {
39
+ lines.push("(尚无里程碑,用 add_milestone 创建)");
40
+ return lines;
41
+ }
42
+ for (const m of sorted) {
43
+ const s = milestoneStats(tasks, m.id);
44
+ lines.push(`${STATUS_ICON[m.status] ?? "?"} ${bar(s.pct)} ${s.pct}% ${m.id} ${m.title}(${s.done}/${s.total})`);
45
+ if (depth >= 2 && (m.status === "active" || m.status === "paused")) {
46
+ const own = tasks
47
+ .filter((t) => t.milestone === m.id && t.status !== "cancelled" && t.status !== "done")
48
+ .sort((a, b) => {
49
+ const order = ["in_progress", "blocked", "todo", "backlog"];
50
+ return order.indexOf(a.status) - order.indexOf(b.status);
51
+ })
52
+ .slice(0, maxTasksPerMilestone);
53
+ if (own.length === 0) {
54
+ lines.push(` - (该里程碑暂无未完成任务)`);
55
+ }
56
+ for (const t of own) {
57
+ const mark = t.status === "in_progress" ? "🔄" : t.status === "blocked" ? "🚫" : "☐";
58
+ const next = t.checkpoint ? ` → 下一步: ${t.checkpoint.next_step}${checkpointSuffix(t.checkpoint)}` : "";
59
+ lines.push(` - ${mark} ${t.id} ${t.title}${next}`);
60
+ }
61
+ }
62
+ }
63
+ return lines;
64
+ }
65
+ /** 里程碑重构配额检查:任务数达到阈值后,refactor+debt 占比低于配额即告警 */
66
+ export function quotaWarnings(project, milestones, tasks) {
67
+ const out = [];
68
+ for (const m of milestones) {
69
+ const s = milestoneStats(tasks, m.id);
70
+ if (s.total >= 5 && m.status !== "done" && s.refactorPct < project.budgets.refactorQuotaPct) {
71
+ out.push({
72
+ milestone: `${m.id} ${m.title}`,
73
+ total: s.total,
74
+ refactorPct: s.refactorPct,
75
+ quotaPct: project.budgets.refactorQuotaPct,
76
+ });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /** mermaid 里程碑图(GitHub 可渲染;最多 8 个,防 token 膨胀) */
82
+ export function mermaidRoadmap(milestones) {
83
+ const sorted = [...milestones].sort((a, b) => a.order - b.order).slice(0, 8);
84
+ if (sorted.length === 0)
85
+ return [];
86
+ const lines = ["```mermaid", "flowchart LR"];
87
+ for (const m of sorted) {
88
+ const label = `${m.id} ${m.title}`.replace(/["<>]/g, "");
89
+ lines.push(` ${m.id.replace(/-/g, "_")}["${label}"]:::${m.status}`);
90
+ }
91
+ for (let i = 0; i + 1 < sorted.length; i++) {
92
+ lines.push(` ${sorted[i].id.replace(/-/g, "_")} --> ${sorted[i + 1].id.replace(/-/g, "_")}`);
93
+ }
94
+ lines.push(" classDef done fill:#9ca3af,stroke:#6b7280", " classDef active fill:#86efac,stroke:#16a34a", " classDef planned fill:#e5e7eb,stroke:#9ca3af", " classDef paused fill:#fde68a,stroke:#d97706", "```");
95
+ return lines;
96
+ }