@luckychen1993/pm-mcp 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/setup.js CHANGED
@@ -2,24 +2,42 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
- export const PACKAGE_SPEC = "@luckychen1993/pm-mcp@0.1.4";
6
- export const SERVER_NAME = "pm-mcp";
7
- const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode"];
5
+ import { randomUUID } from "node:crypto";
6
+ import { fileURLToPath } from "node:url";
7
+ import { initProject } from "./init.js";
8
+ import { isInitialized } from "./paths.js";
9
+ import { refreshDerived } from "./dashboard.js";
10
+ import { PACKAGE_SPEC, SERVER_NAME } from "./version.js";
11
+ export { PACKAGE_SPEC, SERVER_NAME } from "./version.js";
12
+ import { appendCodexProjectConfig } from "./setup-project.js";
13
+ export { appendCodexProjectConfig, codexProjectServerKey } from "./setup-project.js";
14
+ const CLIENTS = ["codex", "claude", "zcode", "cursor", "vscode", "workbuddy"];
8
15
  export function parseSetupArgs(argv) {
9
- const options = { client: "auto", force: false, dryRun: false, help: false };
16
+ const options = { client: "auto", force: false, dryRun: false, help: false, project: false };
10
17
  for (let index = 0; index < argv.length; index += 1) {
11
18
  const arg = argv[index];
12
19
  if (arg === "--client") {
13
20
  const value = argv[index + 1];
14
- if (!value || !["auto", "all", "codex", "claude", "zcode", "cursor", "vscode", "print"].includes(value)) {
15
- throw new Error("--client must be auto, all, codex, claude, zcode, cursor, vscode, or print");
21
+ if (!value || !["auto", "all", "codex", "claude", "zcode", "cursor", "vscode", "workbuddy", "print"].includes(value)) {
22
+ throw new Error("--client must be auto, all, codex, claude, zcode, cursor, vscode, workbuddy, or print");
16
23
  }
17
24
  options.client = value;
18
25
  index += 1;
19
26
  }
27
+ else if (arg === "--project") {
28
+ options.project = true;
29
+ const value = argv[index + 1];
30
+ if (value && !value.startsWith("--")) {
31
+ options.projectDir = value;
32
+ index += 1;
33
+ }
34
+ }
20
35
  else if (arg === "--force") {
21
36
  options.force = true;
22
37
  }
38
+ else if (arg === "--local") {
39
+ options.local = true;
40
+ }
23
41
  else if (arg === "--dry-run") {
24
42
  options.dryRun = true;
25
43
  }
@@ -30,8 +48,29 @@ export function parseSetupArgs(argv) {
30
48
  throw new Error(`unknown setup argument: ${arg}`);
31
49
  }
32
50
  }
51
+ if (options.local && options.client !== "codex" && options.client !== "workbuddy" && options.client !== "print")
52
+ throw new Error("--local 目前只支持 --client codex、workbuddy 或 print");
53
+ if (options.local && options.client === "codex" && !options.project)
54
+ throw new Error("Codex --local 需要 --project 明确项目根");
33
55
  return options;
34
56
  }
57
+ /**
58
+ * 本地已构建入口的启动定义。
59
+ *
60
+ * root 省略时不写 `--root`:宿主客户端会把**会话工作区**作为子进程 cwd,
61
+ * 服务端按 cwd 解析项目根。WorkBuddy 这类多项目宿主依赖这一行为,
62
+ * 显式钉根反而会把所有工作区都指到同一个项目。
63
+ */
64
+ export function localServerLaunch(root) {
65
+ const entry = fileURLToPath(new URL("../dist/index.js", import.meta.url));
66
+ if (!fs.existsSync(entry) || !fs.lstatSync(entry).isFile())
67
+ throw new Error("本地入口未构建,请先 npm run build。");
68
+ return { command: process.execPath, args: root ? [entry, "--root", path.resolve(root)] : [entry] };
69
+ }
70
+ /** 钉定单个项目根的本地启动定义(Codex `--project` 等场景)。 */
71
+ export function localProjectLaunch(root) {
72
+ return localServerLaunch(root);
73
+ }
35
74
  export function commandExists(command, env = process.env, platform = process.platform) {
36
75
  const searchPath = env.PATH ?? env.Path ?? "";
37
76
  const suffixes = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
@@ -57,6 +96,9 @@ export function detectClients(context) {
57
96
  found.push("cursor");
58
97
  if (context.commandExists("code"))
59
98
  found.push("vscode");
99
+ // WorkBuddy 桌面版:`codebuddy`/`workbuddy` 命令、WORKBUDDY_CONFIG_DIR,或既有配置目录任一命中即视为已安装。
100
+ if (context.commandExists("workbuddy") || context.commandExists("codebuddy") || Boolean(env.WORKBUDDY_CONFIG_DIR) || exists(path.join(home, ".workbuddy-ai")) || exists(path.join(home, ".workbuddy")))
101
+ found.push("workbuddy");
60
102
  return found;
61
103
  }
62
104
  function asObject(value, label) {
@@ -64,7 +106,10 @@ function asObject(value, label) {
64
106
  throw new Error(`${label} must be a JSON object`);
65
107
  return value;
66
108
  }
67
- export function mergeJsonServer(document, shape) {
109
+ function defaultJsonLaunch() {
110
+ return { command: "npx", args: ["-y", PACKAGE_SPEC], env: {} };
111
+ }
112
+ export function mergeJsonServer(document, shape, launch = defaultJsonLaunch()) {
68
113
  const root = asObject(document, "configuration root");
69
114
  let servers;
70
115
  if (shape === "zcode") {
@@ -77,14 +122,14 @@ export function mergeJsonServer(document, shape) {
77
122
  const currentServers = root.mcpServers === undefined ? (root.mcpServers = {}) : root.mcpServers;
78
123
  servers = asObject(currentServers, "mcpServers");
79
124
  }
80
- servers[SERVER_NAME] = { command: "npx", args: ["-y", PACKAGE_SPEC], env: {} };
125
+ servers[SERVER_NAME] = { ...launch, env: launch.env ?? {} };
81
126
  return root;
82
127
  }
83
128
  function backupName(file) {
84
129
  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "");
85
- return `${file}.backup-${stamp}-${process.pid}`;
130
+ return `${file}.backup-${stamp}-${process.pid}-${randomUUID()}`;
86
131
  }
87
- export function writeJsonClientConfig(file, shape, dryRun = false) {
132
+ export function writeJsonClientConfig(file, shape, dryRun = false, launch) {
88
133
  const absolute = path.resolve(file);
89
134
  const exists = fs.existsSync(absolute);
90
135
  let document = {};
@@ -99,7 +144,7 @@ export function writeJsonClientConfig(file, shape, dryRun = false) {
99
144
  throw new Error(`invalid JSON in ${absolute}; no changes made: ${error instanceof Error ? error.message : String(error)}`);
100
145
  }
101
146
  }
102
- const merged = mergeJsonServer(document, shape);
147
+ const merged = mergeJsonServer(document, shape, launch);
103
148
  if (dryRun)
104
149
  return null;
105
150
  fs.mkdirSync(path.dirname(absolute), { recursive: true });
@@ -126,6 +171,26 @@ function codexConfigPath(env, home) {
126
171
  function zcodeConfigPath(env, home) {
127
172
  return path.join(env.ZCODE_HOME || path.join(home, ".zcode"), "cli", "config.json");
128
173
  }
174
+ /**
175
+ * WorkBuddy 桌面版的用户级 MCP 配置路径。
176
+ *
177
+ * 优先级:`WORKBUDDY_CONFIG_DIR` → `~/.workbuddy-ai`(当前版本数据目录)→ `~/.workbuddy`(旧版目录)→ 默认当前版本目录。
178
+ * 文件名固定 `mcp.json`,结构为标准 `{ "mcpServers": { ... } }`。
179
+ *
180
+ * 注意:WorkBuddy 的 MCP 子进程由会话工作区拉起,cwd 即当前项目,
181
+ * 因此这里**不写 `--root`**,一个条目即可服务所有工作区。
182
+ */
183
+ export function workbuddyConfigPath(env, home, exists) {
184
+ if (env.WORKBUDDY_CONFIG_DIR)
185
+ return path.join(env.WORKBUDDY_CONFIG_DIR, "mcp.json");
186
+ const current = path.join(home, ".workbuddy-ai");
187
+ if (exists(current))
188
+ return path.join(current, "mcp.json");
189
+ const legacy = path.join(home, ".workbuddy");
190
+ if (exists(legacy))
191
+ return path.join(legacy, "mcp.json");
192
+ return path.join(current, "mcp.json");
193
+ }
129
194
  function hasCodexServer(text) {
130
195
  return /^\s*\[mcp_servers\.(?:pm-mcp|"pm-mcp")\]\s*$/m.test(text);
131
196
  }
@@ -177,8 +242,12 @@ function configureCliClient(client, options, available, log) {
177
242
  throw new Error(`${client} could not add ${SERVER_NAME}`);
178
243
  return true;
179
244
  }
180
- function configureClient(client, options, context, log) {
245
+ function configureClient(client, options, context, log, projectRoot) {
181
246
  if (client === "codex") {
247
+ if (options.project && projectRoot) {
248
+ appendCodexProjectConfig(codexConfigPath(context.env, context.home), projectRoot, options.force, options.dryRun, log, options.local ? localProjectLaunch(projectRoot) : undefined);
249
+ return;
250
+ }
182
251
  if (configureCliClient("codex", options, context.commandExists("codex"), log))
183
252
  return;
184
253
  appendCodexConfig(codexConfigPath(context.env, context.home), options.dryRun, log);
@@ -197,6 +266,16 @@ function configureClient(client, options, context, log) {
197
266
  log(`[backup] ${backup}`);
198
267
  return;
199
268
  }
269
+ if (client === "workbuddy") {
270
+ const file = workbuddyConfigPath(context.env, context.home, context.exists);
271
+ log(`[${options.dryRun ? "plan" : "write"}] workbuddy: ${file}`);
272
+ // 默认写 npx 发布版;--local 绑定已构建入口,未发布候选走这条路径。
273
+ const launch = options.local ? localServerLaunch() : undefined;
274
+ const backup = writeJsonClientConfig(file, "standard", options.dryRun, launch);
275
+ if (backup)
276
+ log(`[backup] ${backup}`);
277
+ return;
278
+ }
200
279
  if (!context.commandExists("code"))
201
280
  throw new Error("code was not found in PATH");
202
281
  const definition = JSON.stringify({ name: SERVER_NAME, command: "npx", args: ["-y", PACKAGE_SPEC] });
@@ -207,8 +286,12 @@ function configureClient(client, options, context, log) {
207
286
  function printHelp(log) {
208
287
  log("pm-mcp setup — configure local AI coding clients");
209
288
  log("");
210
- log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|print] [--force] [--dry-run]`);
289
+ log(`Usage: npx -y ${PACKAGE_SPEC} setup [--client auto|all|codex|claude|zcode|cursor|vscode|workbuddy|print] [--project [dir]] [--force] [--dry-run] [--local]`);
211
290
  log("Default auto mode configures every detected supported client.");
291
+ log("--project [dir]: 在 Codex 把该目录注册为钉定项目并初始化 .pm(新项目一条命令上手;默认当前目录)");
292
+ log("--local: 使用本包已构建的本地入口,适合未发布候选;--force 仅升级已确认项目的 command/args。");
293
+ log("workbuddy: 写入 <WorkBuddy 数据目录>/mcp.json(标准 mcpServers),不钉根——服务端按会话工作区解析项目根。");
294
+ log("升级后运行 pm-mcp doctor --root <项目目录> 核验;现有客户端仍需重新连接。");
212
295
  }
213
296
  export function runSetup(argv, overrides = {}) {
214
297
  const options = parseSetupArgs(argv);
@@ -218,7 +301,8 @@ export function runSetup(argv, overrides = {}) {
218
301
  return 0;
219
302
  }
220
303
  if (options.client === "print") {
221
- log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { command: "npx", args: ["-y", PACKAGE_SPEC], env: {} } } }, null, 2));
304
+ const launch = options.local ? localProjectLaunch(options.projectDir ?? overrides.cwd ?? process.cwd()) : { command: "npx", args: ["-y", PACKAGE_SPEC] };
305
+ log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { ...launch, env: {} } } }, null, 2));
222
306
  return 0;
223
307
  }
224
308
  const env = overrides.env ?? process.env;
@@ -226,24 +310,42 @@ export function runSetup(argv, overrides = {}) {
226
310
  const context = {
227
311
  env,
228
312
  home,
313
+ cwd: overrides.cwd,
229
314
  commandExists: overrides.commandExists ?? ((command) => commandExists(command, env)),
230
315
  exists: overrides.exists ?? fs.existsSync,
231
316
  };
317
+ const projectRoot = options.project ? path.resolve(options.projectDir?.trim() || context.cwd || process.cwd()) : undefined;
232
318
  const selected = options.client === "all"
233
319
  ? CLIENTS
234
320
  : options.client === "auto"
235
321
  ? detectClients(context)
236
322
  : [options.client];
237
- if (selected.length === 0) {
323
+ if (selected.length === 0 && !options.project) {
238
324
  log("No supported client was detected. Use --client <name>, or --client print for generic JSON.");
239
325
  return 1;
240
326
  }
241
327
  log(`pm-mcp setup ${PACKAGE_SPEC}`);
242
- log(`clients: ${selected.join(", ")}`);
328
+ if (selected.length > 0)
329
+ log(`clients: ${selected.join(", ")}`);
330
+ if (options.project && projectRoot) {
331
+ const name = path.basename(projectRoot) || "project";
332
+ log(`project: ${projectRoot}`);
333
+ if (options.dryRun) {
334
+ log(`[plan] init_project ${name}(.pm/ + PROJECT.md + AGENTS.md 工作规矩)`);
335
+ }
336
+ else if (isInitialized(projectRoot)) {
337
+ log("[skip] 项目已初始化(.pm/project.json 已存在)。");
338
+ }
339
+ else {
340
+ initProject(projectRoot, { name });
341
+ refreshDerived(projectRoot);
342
+ log(`[init] ✅ ${name} 已初始化:.pm/ 状态目录 + PROJECT.md 仪表盘 + AGENTS.md 工作规矩`);
343
+ }
344
+ }
243
345
  let failures = 0;
244
346
  for (const client of selected) {
245
347
  try {
246
- configureClient(client, options, context, log);
348
+ configureClient(client, options, context, log, projectRoot);
247
349
  }
248
350
  catch (error) {
249
351
  failures += 1;
@@ -252,6 +354,22 @@ export function runSetup(argv, overrides = {}) {
252
354
  }
253
355
  if (failures > 0)
254
356
  return 1;
255
- log("Done. Restart the configured client and ask it to call pm-mcp get_status.");
357
+ if (options.project && selected.includes("codex")) {
358
+ log("完成。请完全退出并重启 Codex,开新会话即可使用(工具名前缀 pm-mcp-<项目名>)。");
359
+ }
360
+ else if (selected.length === 1 && selected[0] === "workbuddy") {
361
+ log("完成。请在 WorkBuddy 的「连接器管理 → 自定义连接器」中确认信任 pm-mcp,再重开会话调用 get_status 核验。");
362
+ }
363
+ else {
364
+ // 多客户端时逐条给出各自的重连/授权要求,避免只提其中一个造成误导。
365
+ const notes = [];
366
+ if (selected.includes("workbuddy"))
367
+ notes.push("WorkBuddy 需在「连接器管理 → 自定义连接器」确认信任 pm-mcp");
368
+ if (selected.includes("codex"))
369
+ notes.push("Codex 需完全退出并重启");
370
+ log(notes.length > 0
371
+ ? `完成。重启已配置的客户端后调用 get_status 核验;${notes.join(";")}。`
372
+ : "Done. Restart the configured client and ask it to call pm-mcp get_status.");
373
+ }
256
374
  return 0;
257
375
  }
@@ -0,0 +1,110 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { z } from "zod";
5
+ import { fingerprintProject } from "./project-fingerprint.js";
6
+ import { changedGitFiles, projectFilePath } from "./git-state.js";
7
+ import { SCAN_IGNORE_DIRS, ignoreMatcher, projectScanIgnores } from "./scan-policy.js";
8
+ import { loadProject } from "./store.js";
9
+ import { evidenceLevel, weakestEvidence } from "./quality-evidence.js";
10
+ const fingerprint = z.object({ algorithm: z.literal("sha256-tree-v1"), sha256: z.string().regex(/^[a-f0-9]{64}$/) });
11
+ const count = z.number().int().nonnegative().nullable();
12
+ const reportSchema = z.object({
13
+ schema_version: z.literal(2), run_at: z.string().datetime(), ok: z.boolean(), execute: z.boolean(),
14
+ source: z.object({ before: fingerprint, after: fingerprint, stable: z.boolean() }).nullable(),
15
+ results: z.array(z.object({
16
+ unit_root: z.string(), kind: z.string(), command: z.string().min(1), args: z.array(z.string()),
17
+ status: z.string(), exit_code: z.number().nullable(), signal: z.string().nullable(), output_truncated: z.boolean(),
18
+ output_summary: z.object({ tests: count, passed: count.optional(), failed: count, skipped: count, cancelled: count, todo: count, coverage: z.unknown().optional() }),
19
+ })).min(1),
20
+ });
21
+ /** 只读取当前项目内生成的质量报告;链接、损坏和旧格式不冒充证据。 */
22
+ export function verifiedTaskEvidence(root, requested, files = []) {
23
+ const absolute = fs.realpathSync.native(path.resolve(root));
24
+ const anchors = files.map(projectFilePath);
25
+ if (anchors.length === 0)
26
+ throw new Error("完成开发/修复任务需要通过 files 关联实际实现或测试文件。");
27
+ const excluded = ignoreMatcher(projectScanIgnores(root));
28
+ for (const rel of anchors) {
29
+ const parts = rel.split("/");
30
+ if (rel === "PROJECT.md" || parts.some((part, index) => (index < parts.length - 1 && SCAN_IGNORE_DIRS.has(part)) || excluded(parts.slice(0, index + 1).join("/"), index < parts.length - 1))) {
31
+ throw new Error(`任务文件不在源码验证范围内: ${rel}`);
32
+ }
33
+ for (let index = 0; index < parts.length; index++) {
34
+ const target = path.join(absolute, ...parts.slice(0, index + 1));
35
+ let stat;
36
+ try {
37
+ stat = fs.lstatSync(target);
38
+ }
39
+ catch (error) {
40
+ if (error.code !== "ENOENT")
41
+ throw error;
42
+ if (!changedGitFiles(absolute).includes(rel))
43
+ throw new Error(`任务关联文件不存在,也没有 Git 删除记录: ${rel}`);
44
+ break;
45
+ }
46
+ if (stat.isSymbolicLink() || (index === parts.length - 1 ? !stat.isFile() : !stat.isDirectory())) {
47
+ throw new Error(`任务必须关联项目内的普通文件,禁止链接或目录: ${rel}`);
48
+ }
49
+ }
50
+ }
51
+ const directory = path.join(absolute, ".pm", "quality-runs");
52
+ for (const part of [path.join(absolute, ".pm"), directory]) {
53
+ if (!fs.existsSync(part))
54
+ throw new Error("完成开发/修复任务前请执行 run_quality_matrix,缺少质量验证报告。");
55
+ const stat = fs.lstatSync(part);
56
+ if (!stat.isDirectory() || stat.isSymbolicLink())
57
+ throw new Error("质量证据目录必须是项目内的普通目录。");
58
+ }
59
+ const reports = fs.readdirSync(directory).filter((name) => /^quality-.*\.json$/.test(name)).map((name) => {
60
+ const file = path.join(directory, name);
61
+ const stat = fs.lstatSync(file);
62
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024)
63
+ throw new Error("质量证据必须是有界普通文件,禁止符号链接。");
64
+ const raw = fs.readFileSync(file, "utf8");
65
+ const value = JSON.parse(raw);
66
+ const header = z.object({ run_at: z.string().datetime() }).safeParse(value);
67
+ if (!header.success)
68
+ throw new Error(`质量报告 ${name} 缺少有效生成时间,无法判断新鲜度。`);
69
+ return { relative: `.pm/quality-runs/${name}`, raw, value, runAt: header.data.run_at };
70
+ }).sort((a, b) => b.runAt.localeCompare(a.runAt) || b.relative.localeCompare(a.relative));
71
+ const latest = reports[0];
72
+ if (!latest)
73
+ throw new Error("缺少质量验证报告,请先执行 run_quality_matrix。");
74
+ if (requested !== undefined && projectFilePath(requested) !== latest.relative)
75
+ throw new Error("必须使用最新质量报告,不能绕过后来出现的验证结果。");
76
+ const parsed = reportSchema.safeParse(latest.value);
77
+ if (!parsed.success)
78
+ throw new Error("最新质量报告无效或格式过旧,请重新执行验证。");
79
+ const report = parsed.data;
80
+ if (!report.ok || !report.execute || !report.source?.stable || report.source.before.sha256 !== report.source.after.sha256 ||
81
+ report.results.some((item) => item.status !== "passed" || item.exit_code !== 0 || item.signal !== null || item.output_truncated ||
82
+ [item.output_summary.failed, item.output_summary.skipped, item.output_summary.cancelled, item.output_summary.todo].some((n) => n !== null && n > 0))) {
83
+ throw new Error("最新质量验证失败、未执行、输出不完整或存在跳过项,不能标记完成。");
84
+ }
85
+ const tests = report.results.filter((item) => item.kind === "test" || item.kind === "coverage");
86
+ if (tests.length === 0 || tests.some((item) => item.output_summary.tests === 0))
87
+ throw new Error("完成开发/修复任务需要实际测试或覆盖率执行证据。");
88
+ const level = weakestEvidence(tests.map((item) => evidenceLevel(item.kind, item.output_summary, item.output_truncated)));
89
+ const policy = loadProject(root).completion_evidence;
90
+ if (level === "execution_only" && policy?.minimum !== "execution_only") {
91
+ throw new Error("仅有命令执行证据,未观察到完整测试计数。默认完成策略要求 tests_observed;请使用可识别的测试摘要,或由项目负责人明确设置兼容策略并说明理由。");
92
+ }
93
+ const within = (base, target) => {
94
+ const relative = path.relative(base, target);
95
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
96
+ };
97
+ if (tests.some((item) => !path.isAbsolute(item.unit_root)))
98
+ throw new Error("质量报告的执行目录不属于当前项目。");
99
+ const unitRoots = tests.map((item) => fs.realpathSync.native(item.unit_root));
100
+ if (unitRoots.some((unit) => !within(absolute, unit)))
101
+ throw new Error("质量报告的执行目录不属于当前项目。");
102
+ if (anchors.some((file) => !unitRoots.some((unit) => within(unit, path.join(absolute, file))))) {
103
+ throw new Error("质量报告的测试单元未覆盖任务关联文件所在目录。");
104
+ }
105
+ const current = fingerprintProject(absolute);
106
+ if (current.sha256 !== report.source.after.sha256)
107
+ throw new Error("质量验证对应的源码已变化,证据过期,请重新验证。");
108
+ const digest = createHash("sha256").update(latest.raw).digest("hex");
109
+ return `质量报告: ${latest.relative}\n报告 SHA-256: ${digest}\n源码 SHA-256: ${current.sha256}\n执行时间: ${report.run_at}\n证据等级: ${level}${policy ? `\n项目策略: ${policy.minimum};${policy.reason}` : "\n项目策略: tests_observed(默认)"}`;
110
+ }
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { z } from "zod";
5
+ import { loadTasks } from "./store.js";
6
+ import { TaskStatusSchema, TaskTypeSchema } from "./types.js";
7
+ const CursorSchema = z.object({ v: z.literal(1), revision: z.string().regex(/^[a-f0-9]{64}$/), offset: z.number().int().positive(), size: z.number().int().min(1).max(100) }).strict();
8
+ export const TaskPageSchema = z.object({
9
+ total: z.number().int().nonnegative(), all_total: z.number().int().nonnegative(),
10
+ offset: z.number().int().nonnegative(), page_size: z.number().int().min(1).max(100), returned: z.number().int().nonnegative(),
11
+ has_more: z.boolean(), next_cursor: z.string().nullable(), revision: z.string(),
12
+ items: z.array(z.object({ id: z.string(), title: z.string(), status: TaskStatusSchema, type: TaskTypeSchema,
13
+ priority: z.string().nullable(), milestone: z.string().nullable(), next_step: z.string().nullable(), abbreviated: z.boolean() })),
14
+ });
15
+ /** 游标是读取位置,不是授权凭据;绑定项目、过滤及同一份已读取的账本快照。 */
16
+ export function taskPage(root, query, maximum = 100, data = loadTasks(root)) {
17
+ const filters = { status: query.status ?? null, type: query.type ?? null, milestone: query.milestone ?? null, tag: query.tag ?? null, include_done: query.include_done ?? false };
18
+ let canonical = fs.realpathSync.native(path.resolve(root));
19
+ if (process.platform === "win32")
20
+ canonical = canonical.toLowerCase();
21
+ const revision = createHash("sha256").update(canonical).update("\0").update(JSON.stringify(filters)).update("\0").update(JSON.stringify(data)).digest("hex");
22
+ let cursor;
23
+ if (query.cursor !== undefined) {
24
+ try {
25
+ if (query.cursor.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(query.cursor))
26
+ throw new Error();
27
+ cursor = CursorSchema.parse(JSON.parse(Buffer.from(query.cursor, "base64url").toString("utf8")));
28
+ }
29
+ catch {
30
+ throw new Error("分页游标无效,请从第一页重新读取。");
31
+ }
32
+ if (cursor.revision !== revision)
33
+ throw new Error("任务数据、项目或过滤条件已变化,游标失效,请重新读取第一页。");
34
+ }
35
+ const limit = Math.max(1, Math.min(100, Math.floor(maximum)));
36
+ const requested = query.page_size ?? cursor?.size ?? 25;
37
+ if (!Number.isInteger(requested) || requested < 1 || requested > 100)
38
+ throw new Error("page_size 必须为 1 到 100。");
39
+ const size = Math.min(requested, limit);
40
+ if (cursor && (cursor.size !== size || cursor.offset % size !== 0))
41
+ throw new Error("分页大小或输出预算已变化,请重新读取第一页。");
42
+ const list = data.tasks.filter((task) => (query.status ? task.status === query.status : query.include_done || !["done", "cancelled"].includes(task.status)) &&
43
+ (!query.type || task.type === query.type) && (!query.milestone || task.milestone === query.milestone) && (!query.tag || task.tags.includes(query.tag)));
44
+ const offset = cursor?.offset ?? 0;
45
+ if (cursor && offset >= list.length)
46
+ throw new Error("分页游标越界,请重新读取第一页。");
47
+ const brief = (text, length) => { const one = text.replace(/\s+/g, " "); return one.length > length ? one.slice(0, length) + "…" : one; };
48
+ const items = list.slice(offset, offset + size).map((task) => {
49
+ const title = brief(task.title, 160), nextStep = task.checkpoint ? brief(task.checkpoint.next_step, 80) : null;
50
+ return { id: task.id, title, status: task.status, type: task.type, priority: task.priority, milestone: task.milestone,
51
+ next_step: nextStep, abbreviated: title !== task.title || nextStep !== (task.checkpoint?.next_step ?? null) };
52
+ });
53
+ const next = offset + items.length;
54
+ return { total: list.length, all_total: data.tasks.length, offset, page_size: size, returned: items.length, has_more: next < list.length,
55
+ next_cursor: next < list.length ? Buffer.from(JSON.stringify({ v: 1, revision, offset: next, size })).toString("base64url") : null, revision, items };
56
+ }
57
+ export function taskPageText(page) {
58
+ return [
59
+ `共 ${page.total} 个任务(全部 ${page.all_total} 个);本页 ${page.offset + (page.returned ? 1 : 0)}-${page.offset + page.returned},返回 ${page.returned} 个。`,
60
+ ...page.items.map((task) => `${task.status === "in_progress" ? "🔄" : task.status === "blocked" ? "🚫" : task.status === "done" ? "✅" : "☐"} ${task.id} [${task.status}/${task.type}${task.priority ? `/${task.priority}` : ""}] ${task.title}${task.next_step ? ` → ${task.next_step}` : ""}`),
61
+ page.next_cursor ? `下一页:保持过滤条件,list_tasks 传 cursor=${page.next_cursor}` : "已到末页,无更多任务。",
62
+ ...(page.items.some((task) => task.abbreviated) ? ["长标题或下一步已缩略;任务编号与分页条目完整。"] : []),
63
+ ].join("\n");
64
+ }
@@ -1,10 +1,14 @@
1
1
  import { z } from "zod";
2
2
  import { refreshDerived } from "./dashboard.js";
3
- import { foldLines, normSep } from "./budget.js";
3
+ import { normSep } from "./budget.js";
4
4
  import { requireInitialized } from "./paths.js";
5
5
  import { loadRoadmap, loadTasks, nextId, saveTasks } from "./store.js";
6
6
  import { now } from "./types.js";
7
7
  import { budgetLines, toolR, toolW } from "./tool-base.js";
8
+ import { captureFileHashes } from "./git-state.js";
9
+ import { appendSession } from "./session-log.js";
10
+ import { verifiedTaskEvidence } from "./task-evidence.js";
11
+ import { taskPage, taskPageText, TaskPageSchema } from "./task-pagination.js";
8
12
  const stepSchema = z.object({ text: z.string(), done: z.boolean().optional() });
9
13
  export function registerTaskTools(server, root) {
10
14
  toolW(server, root, "add_task", "添加任务。长任务务必给 steps(显式步骤清单,断点续做的载体);走了捷径 type=debt 登记(债务账);修 bug type=fix。", {
@@ -55,43 +59,19 @@ export function registerTaskTools(server, root) {
55
59
  : "";
56
60
  return `✅ 任务 ${id}「${args.title}」已创建(${args.type ?? "feature"}/${args.milestone ?? "无里程碑"})。${tip}`;
57
61
  });
58
- toolR(server, root, "list_tasks", "列出任务(默认不含 done/cancelled)。可按状态/类型/里程碑/标签过滤。", {
62
+ toolR(server, root, "list_tasks", "分页列出任务(默认不含 done/cancelled,每页25)。按原过滤条件传 next_cursor 可取下一页;数据变化会使游标失效。文字摘要与结构化分页结果同时返回,不漏掉折叠的中间任务。", {
59
63
  status: z.enum(["backlog", "todo", "in_progress", "blocked", "done", "cancelled"]).optional(),
60
64
  type: z.enum(["feature", "refactor", "fix", "chore", "debt"]).optional(),
61
65
  milestone: z.string().optional(),
62
66
  tag: z.string().optional(),
63
67
  include_done: z.boolean().optional().describe("默认 false"),
68
+ page_size: z.number().int().min(1).max(100).optional().describe("默认25,受项目输出预算限制"),
69
+ cursor: z.string().min(1).max(1024).optional().describe("上页返回的 next_cursor;保留相同过滤条件"),
64
70
  }, (args) => {
65
71
  requireInitialized(root);
66
- const { tasks } = loadTasks(root);
67
- let list = tasks;
68
- if (args.status)
69
- list = list.filter((t) => t.status === args.status);
70
- else if (!args.include_done)
71
- list = list.filter((t) => t.status !== "done" && t.status !== "cancelled");
72
- if (args.type)
73
- list = list.filter((t) => t.type === args.type);
74
- if (args.milestone)
75
- list = list.filter((t) => t.milestone === args.milestone);
76
- if (args.tag)
77
- list = list.filter((t) => t.tags.includes(args.tag));
78
- const L = list.map((t) => {
79
- const mark = t.status === "in_progress" ? "🔄" : t.status === "blocked" ? "🚫" : t.status === "done" ? "✅" : "☐";
80
- const extra = [];
81
- if (t.priority)
82
- extra.push(t.priority);
83
- if (t.type !== "feature")
84
- extra.push(t.type);
85
- if (t.checkpoint)
86
- extra.push(`下一步: ${t.checkpoint.next_step}`);
87
- return `${mark} ${t.id} ${t.title}${extra.length ? `(${extra.join(", ")})` : ""}`;
88
- });
89
- return foldLines([`共 ${list.length} 个任务(全部 ${tasks.length} 个):`, ...L], {
90
- maxLines: budgetLines(root),
91
- hint: "用 status/type/milestone 过滤",
92
- });
93
- });
94
- toolW(server, root, "update_task", "更新任务。转 done 必须填 result_note;feature/fix 建议填 verification(用什么命令/测试证明)。step_done 勾选第 N 步(从 1 起)。steps 整体替换步骤清单。", {
72
+ return JSON.stringify(taskPage(root, args, budgetLines(root) - 4));
73
+ }, { outputSchema: TaskPageSchema.shape, decode: (value) => { const page = TaskPageSchema.parse(JSON.parse(value)); return { text: taskPageText(page), data: page }; } });
74
+ toolW(server, root, "update_task", "一次更新任务、checkpoint 与会话。checkpoint 默认记会话;完成时用 record_session:true 一并记录,不再另调 log_session;files 给实际关联文件。feature/fix 转 done 必须有最新成功且源码匹配的测试报告,verification_run 可指明报告。文字 verification 不能代替执行证据。", {
95
75
  id: z.string(),
96
76
  status: z.enum(["backlog", "todo", "in_progress", "blocked", "done", "cancelled"]).optional(),
97
77
  title: z.string().optional(),
@@ -103,20 +83,27 @@ export function registerTaskTools(server, root) {
103
83
  acceptance: z.string().optional(),
104
84
  result_note: z.string().optional().describe("完成笔记(转 done 必填)"),
105
85
  verification: z.string().optional().describe("怎么验证的:命令/测试名"),
86
+ verification_run: z.string().min(1).optional().describe(".pm/quality-runs 内最新的质量报告;省略时自动选最新"),
87
+ checkpoint: z.object({ note: z.string().trim().min(1), next_step: z.string().trim().min(1) }).optional(),
88
+ record_session: z.boolean().optional().describe("checkpoint 默认 true;完成任务时设 true 合并会话。省略保留旧式单独记录方式"),
106
89
  steps: z.array(stepSchema).optional().describe("整体替换步骤清单"),
107
90
  step_done: z.number().int().min(1).optional().describe("勾选完成第 N 步"),
108
91
  author: z.string().optional(),
109
- }, (args) => {
92
+ }, (args, prepared) => {
110
93
  requireInitialized(root);
111
94
  const data = loadTasks(root);
112
95
  const t = data.tasks.find((x) => x.id === args.id);
113
96
  if (!t)
114
97
  throw new Error(`找不到任务 ${args.id}。`);
98
+ if (JSON.stringify(t) !== prepared.taskSnapshot)
99
+ throw new Error("任务在证据校验期间发生变化,请重新读取状态。");
115
100
  const tnow = now();
116
101
  if (args.title !== undefined)
117
102
  t.title = args.title;
118
103
  if (args.detail !== undefined)
119
104
  t.detail = args.detail;
105
+ if (args.type !== undefined)
106
+ t.type = args.type; // schema 接受 type 但 handler 此前未应用(schema-handler 失配 bug)
120
107
  if (args.priority !== undefined)
121
108
  t.priority = args.priority ?? null;
122
109
  if (args.milestone !== undefined) {
@@ -138,6 +125,8 @@ export function registerTaskTools(server, root) {
138
125
  t.result_note = args.result_note;
139
126
  if (args.verification !== undefined)
140
127
  t.verification = args.verification;
128
+ if (prepared.evidence)
129
+ t.verification = prepared.evidence + (args.verification ? `\n验证说明: ${args.verification}` : "");
141
130
  if (args.author !== undefined)
142
131
  t.author = args.author;
143
132
  if (args.steps !== undefined)
@@ -149,6 +138,13 @@ export function registerTaskTools(server, root) {
149
138
  s.done = true;
150
139
  }
151
140
  const notes = [];
141
+ if (args.checkpoint) {
142
+ t.checkpoint = { ...args.checkpoint, at: tnow };
143
+ if (args.status === undefined && (t.status === "backlog" || t.status === "todo")) {
144
+ t.status = "in_progress";
145
+ t.started_at ??= tnow;
146
+ }
147
+ }
152
148
  if (args.status !== undefined && args.status !== t.status) {
153
149
  if (args.status === "done") {
154
150
  const note = args.result_note !== undefined ? args.result_note : t.result_note;
@@ -159,11 +155,6 @@ export function registerTaskTools(server, root) {
159
155
  throw new Error(`result_note 过于空洞(「${note.trim()}」):完成笔记至少要说明做了什么(4 字以上),别拿一个字糊弄账本。`);
160
156
  }
161
157
  t.completed_at = tnow;
162
- if (!t.verification.trim() && (args.verification === undefined || !args.verification.trim())) {
163
- if (t.type === "feature" || t.type === "fix") {
164
- notes.push("💡 提示: 该任务是 " + t.type + " 类型但没填 verification——用什么命令/测试证明它真的好了?建议补上(update_task id=" + t.id + " verification=...)。");
165
- }
166
- }
167
158
  }
168
159
  if (t.status === "done" && args.status !== "done")
169
160
  t.completed_at = null;
@@ -173,9 +164,28 @@ export function registerTaskTools(server, root) {
173
164
  }
174
165
  t.updated = tnow;
175
166
  saveTasks(root, data);
167
+ if (args.record_session ?? (args.checkpoint !== undefined)) {
168
+ const session = appendSession(root, {
169
+ summary: `${t.id} ${t.title}:${args.status === "done" ? t.result_note : args.checkpoint?.note ?? `状态 ${t.status}`}`,
170
+ files: args.files ?? [], next_steps: args.checkpoint ? [args.checkpoint.next_step] : [], author: args.author,
171
+ }, prepared.hashes);
172
+ notes.push(`会话 ${session} 已自动记录,无需重复 log_session。`);
173
+ }
176
174
  refreshDerived(root);
177
175
  const stepsInfo = t.steps.length > 0 ? `,步骤 ${t.steps.filter((s) => s.done).length}/${t.steps.length}` : "";
178
176
  return [`✅ ${t.id} 已更新(${t.status}${stepsInfo})。`, ...notes].join("\n");
177
+ }, (args) => {
178
+ requireInitialized(root);
179
+ const task = loadTasks(root).tasks.find((item) => item.id === args.id);
180
+ if (!task)
181
+ throw new Error(`找不到任务 ${args.id}。`);
182
+ if (args.status === "done" && (args.result_note ?? task.result_note).trim().length < 4) {
183
+ throw new Error("转 done 必须填写 result_note(至少4字,不能过于空洞)。");
184
+ }
185
+ const evidence = args.status === "done" && (task.type === "feature" || task.type === "fix")
186
+ ? verifiedTaskEvidence(root, args.verification_run, args.files ?? task.files) : undefined;
187
+ const record = args.record_session ?? (args.checkpoint !== undefined);
188
+ return { evidence, taskSnapshot: JSON.stringify(task), hashes: record ? captureFileHashes(root, args.files ?? []) : {} };
179
189
  });
180
190
  toolW(server, root, "checkpoint", "断点存档:长任务做到一半(或上下文快满)时随手保存「当前进度 + 下一步动作」。新会话 get_status 会直接给出恢复点,不用靠回忆。", {
181
191
  task_id: z.string(),