@sleep2agi/commhub-server 0.9.0-preview.155 → 0.9.0-preview.157

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.155",
3
+ "version": "0.9.0-preview.157",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -259,6 +259,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
259
259
  request_adopt_node: "human_only", unadopt_node: "human_only",
260
260
  get_adopt_request: "always", ack_adopt_request: "always",
261
261
  send_desktop_message: "always", // 发给人的私信,不是派活
262
+ requirement_tags: "requirements",
262
263
  // 读
263
264
  get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read", org_whoami: "read",
264
265
  list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
@@ -264,3 +264,162 @@ describe("改名 / 合并 / 删除 / 颜色", () => {
264
264
  expect(cardTags("net2")).toEqual(["UI", "交互"]);
265
265
  });
266
266
  });
267
+
268
+ // ── 改父级(reparent) ──
269
+ // 客户端(0.297+)的「管理标签」里要能改父级;Hub 侧此前只缺这一个 op。
270
+ // 这里只验树:reparent 不给标签改名、不动任何卡片的 tags 数组。
271
+ describe("改父级 reparent", () => {
272
+ const createTag = (token: string, name: string, parent: string | null = null) =>
273
+ send(token, "POST", `/api/requirements/tags/create?network_id=${NET}`, { name, parent_id: parent });
274
+ const nodesOf = async (token: string) =>
275
+ (await send(token, "GET", `/api/requirements/tags?network_id=${NET}`)).body.nodes as Array<{ name: string; parent_id: string | null; depth: number; registered: boolean }>;
276
+ const nodeOf = async (token: string, name: string) => (await nodesOf(token)).find(node => node.name === name)!;
277
+
278
+ test("搬到另一个标签下面,再搬回顶层:父与深度都跟着变,卡片一张都不动", async () => {
279
+ expect((await createTag(admin.token, "RP根")).status).toBe(201);
280
+ expect((await createTag(admin.token, "RP枝")).status).toBe(201);
281
+ expect((await createTag(admin.token, "RP叶", "RP枝")).status).toBe(201);
282
+ const before = db.get("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET).n;
283
+
284
+ const moved = await op(admin.token, { op: "reparent", tag: "RP叶", parent: "RP根" });
285
+ expect(moved.status).toBe(200);
286
+ expect(moved.body).toMatchObject({ ok: true, op: "reparent", affected: 0, reparented: 1 });
287
+ expect(await nodeOf(admin.token, "RP叶")).toMatchObject({ parent_id: "RP根", depth: 2 });
288
+ // 原来是 RP枝 的子孙,搬走之后 RP枝 下面没有它了
289
+ expect((await nodesOf(admin.token)).filter(node => node.parent_id === "RP枝")).toEqual([]);
290
+
291
+ const back = await op(admin.token, { op: "reparent", tag: "RP叶", parent: null });
292
+ expect(back.status).toBe(200);
293
+ expect(await nodeOf(admin.token, "RP叶")).toMatchObject({ parent_id: null, depth: 1 });
294
+ // 只动树:卡片数与卡的标签都没变
295
+ expect(db.get("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET).n).toBe(before);
296
+ });
297
+
298
+ test("父是自己的子孙 = 环,拒绝(400 tag_parent_cycle),树一个字都不改", async () => {
299
+ expect((await createTag(admin.token, "RP环甲")).status).toBe(201);
300
+ expect((await createTag(admin.token, "RP环乙", "RP环甲")).status).toBe(201);
301
+ const r = await op(admin.token, { op: "reparent", tag: "RP环甲", parent: "RP环乙" });
302
+ expect(r.status).toBe(400);
303
+ expect(r.body.error).toBe("tag_parent_cycle");
304
+ expect(await nodeOf(admin.token, "RP环甲")).toMatchObject({ parent_id: null });
305
+ });
306
+
307
+ test("自己当自己的父:解析阶段就拒(400 tag_parent_cycle)", async () => {
308
+ const r = await op(admin.token, { op: "reparent", tag: "RP根", parent: "RP根" });
309
+ expect(r.status).toBe(400);
310
+ expect(r.body.error).toBe("tag_parent_cycle");
311
+ });
312
+
313
+ test("连子树一起搬:新父深度 + 子树高度 > 4 就拒(400 tag_depth_exceeded)", async () => {
314
+ expect((await createTag(admin.token, "RPD1")).status).toBe(201);
315
+ expect((await createTag(admin.token, "RPD2", "RPD1")).status).toBe(201);
316
+ expect((await createTag(admin.token, "RPD3", "RPD2")).status).toBe(201); // 3 层:1>2>3
317
+ expect((await createTag(admin.token, "RPX")).status).toBe(201);
318
+ expect((await createTag(admin.token, "RPY", "RPX")).status).toBe(201); // 2 层子树
319
+ // 把 2 层的 RPX(含 RPY)挂到第 3 层下面 → 最深 5 层 > 4
320
+ const r = await op(admin.token, { op: "reparent", tag: "RPX", parent: "RPD3" });
321
+ expect(r.status).toBe(400);
322
+ expect(r.body.error).toBe("tag_depth_exceeded");
323
+ expect(await nodeOf(admin.token, "RPX")).toMatchObject({ parent_id: null });
324
+ // 只搬叶子(高度 1)就正好 4 层,允许
325
+ const ok = await op(admin.token, { op: "reparent", tag: "RPY", parent: "RPD3" });
326
+ expect(ok.status).toBe(200);
327
+ expect(await nodeOf(admin.token, "RPY")).toMatchObject({ parent_id: "RPD3", depth: 4 });
328
+ });
329
+
330
+ test("父不存在 = 404,不静默升到根", async () => {
331
+ const r = await op(admin.token, { op: "reparent", tag: "RP根", parent: "不存在的父" });
332
+ expect(r.status).toBe(404);
333
+ expect(r.body.error).toBe("tag_parent_not_found");
334
+ expect(await nodeOf(admin.token, "RP根")).toMatchObject({ parent_id: null });
335
+ });
336
+
337
+ test("缺 parent 键 = 400(不能用「没传」把标签静默升到根);parent 为空串 = 顶层", async () => {
338
+ const missing = await op(admin.token, { op: "reparent", tag: "RP根" });
339
+ expect(missing.status).toBe(400);
340
+ expect(missing.body.error).toBe("invalid_tag_parent_id");
341
+ expect(await nodeOf(admin.token, "RP根")).toMatchObject({ parent_id: null });
342
+
343
+ expect((await op(admin.token, { op: "reparent", tag: "RP根", parent: "RPD1" })).status).toBe(200);
344
+ const root = await op(admin.token, { op: "reparent", tag: "RP根", parent: "" });
345
+ expect(root.status).toBe(200);
346
+ expect(await nodeOf(admin.token, "RP根")).toMatchObject({ parent_id: null });
347
+ });
348
+
349
+ test("只在卡片上出现过的老标签也能改父级:先登记成节点,再挂到父下面", async () => {
350
+ const inUse = (await nodesOf(admin.token)).find(node => node.registered === false);
351
+ if (!inUse) return; // 该网络里没有"未登记但在用"的标签时,这条不适用
352
+ const r = await op(admin.token, { op: "reparent", tag: inUse.name, parent: "RPD1" });
353
+ expect(r.status).toBe(200);
354
+ const after = await nodeOf(admin.token, inUse.name);
355
+ expect(after).toMatchObject({ parent_id: "RPD1", depth: 2, registered: true });
356
+ // 卡片上的标签字符串不变(改父级不是改名)
357
+ const stillUsed = db.all<{ tags_json: string }>("SELECT tags_json FROM requirements WHERE network_id = ?1 AND tags_json LIKE ?2", NET, `%${inUse.name}%`);
358
+ expect(stillUsed.length).toBeGreaterThan(0);
359
+ });
360
+
361
+ test("reparent 走同一把标签管理锁:节点令牌与 viewer 都拒(403),树不变", async () => {
362
+ const byNode = await op(nodeToken, { op: "reparent", tag: "RP根", parent: "RPD1" });
363
+ expect(byNode.status).toBe(403);
364
+ const byViewer = await op(vic.token, { op: "reparent", tag: "RP根", parent: "RPD1" });
365
+ expect(byViewer.status).toBe(403);
366
+ expect(await nodeOf(admin.token, "RP根")).toMatchObject({ parent_id: null });
367
+ });
368
+ });
369
+
370
+ describe("改父级 reparent —— 被拒绝时不留副作用", () => {
371
+ const nodes = async () => (await send(admin.token, "GET", `/api/requirements/tags?network_id=${NET}`)).body.nodes as Array<{ name: string; parent_id: string | null; registered: boolean }>;
372
+ test("只在卡片上用过的标签,reparent 到不存在的父:404 且**不会**被登记进树", async () => {
373
+ const before = (await nodes()).filter(node => !node.registered).map(node => node.name);
374
+ if (!before.length) return; // 该网络里没有"未登记但在用"的标签时,这条不适用
375
+ const name = before[0];
376
+ const r = await op(admin.token, { op: "reparent", tag: name, parent: "根本没有这个父" });
377
+ expect(r.status).toBe(404);
378
+ expect(r.body.error).toBe("tag_parent_not_found");
379
+ // 关键:事务返回错误码是**提交**语义,若先 INSERT 登记再报错,这里就会看到 registered=true
380
+ const after = (await nodes()).find(node => node.name === name);
381
+ expect(after?.registered ?? false).toBe(false);
382
+ });
383
+ });
384
+
385
+ // ── MCP 面(#2204 缺口 2)──
386
+ // 一个工具(action 分派)而不是 list + op 两个:tools/list 字节预算是硬约束,见 tools.ts 的注释。
387
+ describe("requirement_tags (MCP)", () => {
388
+ async function mcp(token: string, name: string, args: Record<string, unknown>) {
389
+ const r = await fetch(`${BASE}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
390
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
391
+ const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
392
+ const envelope = JSON.parse(line ? line.slice(6) : raw);
393
+ try { return JSON.parse(envelope.result.content[0].text); } catch { return { mcpError: envelope.error ?? envelope.result }; }
394
+ }
395
+ const nodes = async (token: string) => (await send(token, "GET", `/api/requirements/tags?network_id=${NET}`)).body.nodes as Array<{ name: string; parent_id: string | null; depth: number; registered: boolean }>;
396
+
397
+ test("action=list 返回标签树(含 parent/depth/registered);action=reparent 走同一条 REST 语义", async () => {
398
+ expect((await send(admin.token, "POST", `/api/requirements/tags/create?network_id=${NET}`, { name: "MCP父", parent_id: null })).status).toBe(201);
399
+ expect((await send(admin.token, "POST", `/api/requirements/tags/create?network_id=${NET}`, { name: "MCP子", parent_id: null })).status).toBe(201);
400
+
401
+ const listed = await mcp(admin.token, "requirement_tags", { action: "list" });
402
+ const rows = listed.nodes as Array<{ name: string; parent_id: string | null; depth: number; registered: boolean }>;
403
+ expect(rows.find(node => node.name === "MCP父")).toMatchObject({ parent_id: null, depth: 1, registered: true });
404
+ expect(rows.find(node => node.name === "MCP子")).toMatchObject({ parent_id: null, depth: 1 });
405
+
406
+ const moved = await mcp(admin.token, "requirement_tags", { action: "reparent", tag: "MCP子", parent: "MCP父" });
407
+ expect(moved).toMatchObject({ ok: true, op: "reparent", affected: 0, reparented: 1 });
408
+ expect((await nodes(admin.token)).find(node => node.name === "MCP子")).toMatchObject({ parent_id: "MCP父", depth: 2 });
409
+
410
+ // 与 REST 同一套错误码:不传 parent 键 ≠ 升到顶层
411
+ const missing = await mcp(admin.token, "requirement_tags", { action: "reparent", tag: "MCP子" });
412
+ expect(missing.error).toBe("invalid_tag_parent_id");
413
+ expect((await nodes(admin.token)).find(node => node.name === "MCP子")).toMatchObject({ parent_id: "MCP父" });
414
+ });
415
+
416
+ test("节点令牌:能 list,但写操作被拒(403 permission_denied),树不变", async () => {
417
+ const listed = await mcp(nodeToken, "requirement_tags", { action: "list" });
418
+ expect(Array.isArray(listed.nodes)).toBe(true);
419
+ const write = await mcp(nodeToken, "requirement_tags", { action: "reparent", tag: "MCP子", parent: null });
420
+ expect(write.status).toBe(403);
421
+ // 写操作先过「要人的令牌」这道闸(tags_write),再到 owner/admin 判定;节点令牌停在前一道。
422
+ expect(["user_token_required", "permission_denied"]).toContain(write.error);
423
+ expect((await nodes(admin.token)).find(node => node.name === "MCP子")).toMatchObject({ parent_id: "MCP父" });
424
+ });
425
+ });
@@ -95,3 +95,33 @@ export function buildTagNodes(registered: TagNodeRow[], inUse: string[]): TagNod
95
95
  }
96
96
  return nodes.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
97
97
  }
98
+
99
+ /**
100
+ * name 这棵子树有几层(叶子 = 1)。改父级时用它算「搬过去之后最深到几层」:
101
+ * 只检查新父的深度是不够的 —— 一个 3 层的子树挂到第 3 层下,最深会到 5 层。
102
+ * 历史脏数据里有环时用 visited 兜住,不要为脏数据死循环。
103
+ */
104
+ export function tagSubtreeHeight(name: string, parentOf: Map<string, string | null>): number {
105
+ const childrenOf = new Map<string, string[]>();
106
+ for (const [child, parent] of parentOf) {
107
+ if (!parent) continue;
108
+ const list = childrenOf.get(parent);
109
+ if (list) list.push(child); else childrenOf.set(parent, [child]);
110
+ }
111
+ let height = 1;
112
+ let frontier = [name];
113
+ const seen = new Set<string>([name]);
114
+ for (;;) {
115
+ const next: string[] = [];
116
+ for (const node of frontier) {
117
+ for (const child of childrenOf.get(node) ?? []) {
118
+ if (seen.has(child)) continue;
119
+ seen.add(child);
120
+ next.push(child);
121
+ }
122
+ }
123
+ if (!next.length) return height;
124
+ height++;
125
+ frontier = next;
126
+ }
127
+ }
@@ -23,7 +23,9 @@ export type TagOp =
23
23
  | { op: "rename"; from: string; to: string }
24
24
  | { op: "merge"; from: string[]; to: string }
25
25
  | { op: "delete"; tag: string }
26
- | { op: "color"; tag: string; color: string | null };
26
+ | { op: "color"; tag: string; color: string | null }
27
+ // 改父级(标签树自己的层级,与卡片无关):parent=null = 升为顶层。
28
+ | { op: "reparent"; tag: string; parent: string | null };
27
29
 
28
30
  /** 单个标签名,规则同 normalizeTags。不合法 = null。 */
29
31
  export function tagName(value: unknown): string | null {
@@ -66,6 +68,17 @@ export function parseTagOp(body: Record<string, unknown>): TagOp | { error: stri
66
68
  if (body.color !== null && (typeof body.color !== "string" || !TAG_COLOR.test(body.color))) return { error: "invalid_tag_color" };
67
69
  return { op: "color", tag, color: body.color === null ? null : (body.color as string).toLowerCase() };
68
70
  }
71
+ case "reparent": {
72
+ const tag = tagName(body.tag);
73
+ if (!tag) return { error: "invalid_tag" };
74
+ // 与 create 同一套读法:缺省 key 算错(不能靠"没传"把标签静默升到根),显式 null = 根。
75
+ if (!("parent" in body)) return { error: "invalid_tag_parent_id" };
76
+ if (body.parent === null || body.parent === "") return { op: "reparent", tag, parent: null };
77
+ const parent = tagName(body.parent);
78
+ if (!parent) return { error: "invalid_tag_parent_id" };
79
+ if (parent === tag) return { error: "tag_parent_cycle" };
80
+ return { op: "reparent", tag, parent };
81
+ }
69
82
  default:
70
83
  return { error: "invalid_tag_op" };
71
84
  }
@@ -8,7 +8,7 @@ import { db, logAudit } from "./db.js";
8
8
  import { resolveNodeCaller } from "./create-node.js";
9
9
  import { markGzipReusable } from "./http-gzip.js";
10
10
  import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
11
- import { buildTagNodes, MAX_TAG_DEPTH, parseCreateTag, TAG_TREE_CAPABILITY, tagDepth, type TagNodeRow } from "./requirement-tag-tree.js";
11
+ import { buildTagNodes, isDescendant, MAX_TAG_DEPTH, parseCreateTag, TAG_TREE_CAPABILITY, tagDepth, tagSubtreeHeight, type TagNodeRow } from "./requirement-tag-tree.js";
12
12
  import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
13
13
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
14
14
  import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
@@ -547,13 +547,37 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
547
547
  const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
548
548
  // 「存在」= 有卡片在用 / 有颜色行 / 是登记过的树节点。最后一种让 0 任务的标签也能改名、合并、删除、上色。
549
549
  const nodeExists = (name: string) => !!db.get<{ x: number }>("SELECT 1 AS x FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", networkId, name);
550
- const known = cards.some(card => storedTags(card.tags_json).some(tag => sources.includes(tag))) || sources.some(tag => colorOf(tag) !== null || nodeExists(tag));
550
+ // 「存在」的三种形态(卡片在用 / 有颜色行 / 登记过的树节点),reparent 判父存在时用的是同一个口径。
551
+ const tagExists = (name: string) => cards.some(card => storedTags(card.tags_json).includes(name)) || colorOf(name) !== null || nodeExists(name);
552
+ const known = sources.some(tagExists);
551
553
  if (!known) return null;
552
554
  if (op.op === "color") {
553
555
  if (op.color === null) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
554
556
  else db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (network_id, name) DO UPDATE SET color = excluded.color, updated_at = excluded.updated_at", [networkId, op.tag, op.color, now]);
555
557
  return { affected: 0 };
556
558
  }
559
+ // 改父级:只动标签树,不动任何卡片(标签仍然是卡片 tags_json 里的字符串)。
560
+ // 三条规则都明确拒绝而不是静默纠正:父必须存在、不许成环、搬完最深不超过 MAX_TAG_DEPTH。
561
+ if (op.op === "reparent") {
562
+ const nodes = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
563
+ const parentOf = new Map(nodes.map(row => [row.name, row.parent_name ?? null]));
564
+ // 只在卡片上出现过的老标签没有行可改,要登记成顶层节点(这不是把它降级,它本来就没有父)。
565
+ // ⚠ 先**在虚拟树上**判环与深度,判过了再写:事务返回 {error} 是提交而不是回滚,
566
+ // 如果先 INSERT 登记再返回错误,调用方看到的是"被拒绝了",库里却多了一行 —— 树被静默改过。
567
+ const virtual = new Map(parentOf);
568
+ if (!virtual.has(op.tag)) virtual.set(op.tag, null);
569
+ if (op.parent) {
570
+ if (!tagExists(op.parent)) return { error: "tag_parent_not_found" };
571
+ if (!virtual.has(op.parent)) virtual.set(op.parent, null);
572
+ // 环:父是自己(parse 已挡)或自己的子孙(这里挡)。深度:新父的深度 + 自己这棵子树的高度。
573
+ if (isDescendant(op.parent, op.tag, virtual)) return { error: "tag_parent_cycle" };
574
+ if (tagDepth(op.parent, virtual) + tagSubtreeHeight(op.tag, virtual) > MAX_TAG_DEPTH) return { error: "tag_depth_exceeded" };
575
+ }
576
+ if (!parentOf.has(op.tag)) db.run("INSERT INTO network_tag_nodes (network_id, name, parent_name, created_at, updated_at, created_by_json) VALUES (?1, ?2, NULL, ?3, ?3, ?4)", [networkId, op.tag, now, actor]);
577
+ if (op.parent && !parentOf.has(op.parent)) db.run("INSERT INTO network_tag_nodes (network_id, name, parent_name, created_at, updated_at, created_by_json) VALUES (?1, ?2, NULL, ?3, ?3, ?4)", [networkId, op.parent, now, actor]);
578
+ db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND name = ?4", [op.parent, now, networkId, op.tag]);
579
+ return { affected: 0, reparented: 1 };
580
+ }
557
581
  let affected = 0;
558
582
  for (const card of cards) {
559
583
  const next = applyTagOp(storedTags(card.tags_json), op);
@@ -567,13 +591,14 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
567
591
  return { affected, reparented };
568
592
  });
569
593
  if (!result) return jsonError("tag_not_found", 404);
594
+ if ("error" in result) return jsonError(result.error, result.error === "tag_parent_not_found" ? 404 : 400);
570
595
  if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, `requirement_tag_${op.op}`, "requirement_tag", op.op === "rename" || op.op === "merge" ? op.to : op.tag, JSON.stringify({ ...op, affected: result.affected }).slice(0, 1000), undefined, networkId);
571
596
  return Response.json({ ok: true, op: op.op, affected: result.affected, reparented: result.reparented });
572
597
  }
573
598
 
574
599
  /** 改名 / 合并:目标没有颜色就继承第一个有颜色的来源;来源的颜色行删掉。删除:删颜色行。 */
575
600
  function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) => string | null, now: string): void {
576
- if (op.op === "color") return;
601
+ if (op.op === "color" || op.op === "reparent") return;
577
602
  const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
578
603
  if (op.op !== "delete" && colorOf(op.to) === null) {
579
604
  const inherited = sources.map(colorOf).find(color => color !== null);
@@ -590,7 +615,7 @@ function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) =>
590
615
  * 返回被改挂的节点数(客户端能报「N 个子标签已上移」)。
591
616
  */
592
617
  function maintainTagTree(networkId: string, op: TagOp, now: string): number {
593
- if (op.op === "color") return 0;
618
+ if (op.op === "color" || op.op === "reparent") return 0;
594
619
  const rows = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
595
620
  if (!rows.length) return 0;
596
621
  const parentOf = new Map(rows.map(row => [row.name, row.parent_name ?? null]));
@@ -211,6 +211,12 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
211
211
  // assign_agent 合并成一个,尽量小)。实测 node 64,112 B / user 60,117 B,比原上限
212
212
  // 分别高 412 / 279 B —— 这是有意的功能增长(组织架构可由 Agent 直接调),不是文案偷偷变长。
213
213
  // 留约 400 B 余量;再要加工具时请先合并动作或砍描述,别连续抬上限。
214
+ // #2204(2026-10-25):标签管理给 MCP。为了**不抬上限**,做了两件事:
215
+ // ① 合并成一个工具 `requirement_tags`(action 分派 list/rename/merge/delete/color/reparent),
216
+ // 而不是 list + op 两个(照 #1894 `departments` 的先例);
217
+ // ② 砍掉一批冗长描述里重复的解释性从句(每条约 20–100 B,信息点全部保留)。
218
+ // 实测(含新工具):all 97 / 86,020 B;node 64 / 64,464 B;user 64 / 59,680 B —— 三个上限**一个都没动**。
219
+ // 下次再加工具,请先合并动作或砍描述,不要直接改这三个数。
214
220
  const CEILINGS = { node: 64_500, user: 60_500, all: 86_500 } as const;
215
221
  test("node / user / all", async () => {
216
222
  const node = await listTools(T.node_normal);
@@ -43,7 +43,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
43
43
  get_probe_request: "protocol", ack_probe_request: "protocol",
44
44
  get_opencode_catalog_request: "protocol", ack_opencode_catalog: "protocol",
45
45
  // ── 人:节点令牌一律 user_token_required(项目管理)/ node_token_cannot_browse_files / node_token_cannot_read_logs ──
46
- projects_create: "user", projects_update: "user",
46
+ projects_create: "user", projects_update: "user", requirement_tags: "both",
47
47
  read_opencode_catalog: "user", get_opencode_catalog: "user",
48
48
  list_node_files: "user", read_node_file: "user", tail_node_logs: "user",
49
49
  // ── 两边都能调 ──
package/src/tools.ts CHANGED
@@ -1993,7 +1993,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1993
1993
 
1994
1994
  server.tool(
1995
1995
  "send_task",
1996
- "Dispatch a task to a session's inbox (by alias). The reply carries queue_ahead (open tasks already on the target), target_busy, est_wait_minutes (rough, null = no history) and, when the queue is long, a warning: queued, never refused — consider an idle node.",
1996
+ "Dispatch a task to a session's inbox (by alias). The reply carries queue_ahead, target_busy, est_wait_minutes (null = no history) and, when the queue is long, a warning. Always queued, never refused.",
1997
1997
  {
1998
1998
  alias: z.string().min(1).max(200).describe("Target session alias"),
1999
1999
  task: z.string().min(1).max(TASK_CONTENT_MAX).describe("Task content"),
@@ -2844,7 +2844,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2844
2844
  );
2845
2845
  server.tool(
2846
2846
  "send_peer_reply",
2847
- "Atomically finalize one node-owned task and enqueue one no-response result when the exact recipient advertises peer_reply_inbox_capable. Stored capability survives transient SSE disconnects; legacy peers return peer_reply_unsupported with zero writes.",
2847
+ "Atomically finalize one node-owned task and enqueue one no-response result when the recipient advertises peer_reply_inbox_capable. Stored capability mismatch -> refused, not silently degraded.",
2848
2848
  replyToolSchema,
2849
2849
  (args) => handleReply(args, true),
2850
2850
  );
@@ -3465,7 +3465,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3465
3465
 
3466
3466
  server.tool(
3467
3467
  "update_node_config",
3468
- "Set the desired per-node config (model + flags) and push a doorbell to the node. The node pulls + validates + applies (hot or restart per field tier). RFC-024.",
3468
+ "Set the per-node config (model + flags) and push a doorbell; the node pulls, validates and applies (hot or restart per field). RFC-024.",
3469
3469
  {
3470
3470
  ...NODE_ID_ALIAS_FIELDS,
3471
3471
  base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
@@ -4034,7 +4034,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4034
4034
 
4035
4035
  server.tool(
4036
4036
  "read_node_rules_file",
4037
- "Ask a node to send back its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) from its working directory. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
4037
+ "Ask a node to send back its rules file (CLAUDE.md for claude, else AGENTS.md) from its work dir. No path argument. Poll get_rules_file_result with the returned request_id. app#225.",
4038
4038
  {
4039
4039
  ...NODE_ID_ALIAS_FIELDS,
4040
4040
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
@@ -4045,7 +4045,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4045
4045
 
4046
4046
  server.tool(
4047
4047
  "write_node_rules_file",
4048
- "Ask a node to overwrite its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) in its working directory with `content`. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
4048
+ "Ask a node to write a managed block into its rules file (CLAUDE.md / AGENTS.md). Poll get_rules_file_result. User logins only (node owner or network owner/admin).",
4049
4049
  {
4050
4050
  ...NODE_ID_ALIAS_FIELDS,
4051
4051
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
@@ -4057,7 +4057,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4057
4057
 
4058
4058
  server.tool(
4059
4059
  "list_node_skills",
4060
- "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description; team skills also include origin \"team\"). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…], roots, warnings}.",
4060
+ "Ask a node for the skills its runtime loads (name, scope project|user|system, display path, description; team skills add origin \"team\"). No path. Poll get_rules_file_result: {skills:[...], roots, warnings}.",
4061
4061
  {
4062
4062
  ...NODE_ID_ALIAS_FIELDS,
4063
4063
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
@@ -4068,7 +4068,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4068
4068
 
4069
4069
  server.tool(
4070
4070
  "read_node_skill",
4071
- "Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content, origin?}. origin is \"team\" only for a team skill.",
4071
+ "One skill's SKILL.md by name; the node resolves it under its own skills roots (no path). Poll get_rules_file_result: {name, scope, path_rel, description, content, origin?}; origin=\"team\" only for a team skill.",
4072
4072
  {
4073
4073
  ...NODE_ID_ALIAS_FIELDS,
4074
4074
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
@@ -4080,7 +4080,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4080
4080
 
4081
4081
  server.tool(
4082
4082
  "list_node_files",
4083
- "Ask a node to list one directory level of its work dir (project folder view, read-only). `path` is relative to the work dir (default: the root); absolute paths and .. are refused. Poll get_rules_file_result — content is JSON {path, entries:[{name,type,size?,mtime?,hidden_reason?,no_descend?}], truncated, total}. User logins only.",
4083
+ "List one level of the node's work dir (read-only). `path` relative (default root); absolute and .. refused. Poll get_rules_file_result: {path, entries:[{name,type,size?,mtime?,hidden_reason?,no_descend?}], truncated, total}. User logins only.",
4084
4084
  {
4085
4085
  ...NODE_ID_ALIAS_FIELDS,
4086
4086
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
@@ -4092,7 +4092,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4092
4092
 
4093
4093
  server.tool(
4094
4094
  "read_node_file",
4095
- "Ask a node for one text file under its work dir (project folder view, read-only, 256 KiB cap; binary / too large → size only; secret-looking files → name only). `path` is relative to the work dir. Poll get_rules_file_result — content is JSON {path, name, kind, size?, mtime?, content?, hidden_reason?}. User logins only.",
4095
+ "One text file under the node's work dir (read-only, 256 KiB cap; binary / too large -> size only; secret-looking -> name only). `path` relative. Poll get_rules_file_result: {path, name, kind, size?, mtime?, content?, hidden_reason?}. User logins only.",
4096
4096
  {
4097
4097
  ...NODE_ID_ALIAS_FIELDS,
4098
4098
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
@@ -4104,7 +4104,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4104
4104
 
4105
4105
  server.tool(
4106
4106
  "tail_node_logs",
4107
- "Tail of a node's own agent-node run log (read-only, redacted on the node). No path argument: it reads only its own log directory. User logins only (node owner or network owner/admin). Poll get_rules_file_result; JSON {files, lines, truncated, matched}; handed out once, then purged.",
4107
+ "Tail of the node's own run log (read-only, redacted on the node). No path argument. User logins only (node owner or network owner/admin). Poll get_rules_file_result: {files, lines, truncated, matched}; handed out once, then purged.",
4108
4108
  {
4109
4109
  ...NODE_ID_ALIAS_FIELDS,
4110
4110
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting logs_capable)."),
@@ -4217,7 +4217,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4217
4217
 
4218
4218
  server.tool(
4219
4219
  "get_rules_file_result",
4220
- "Poll the outcome of a read_node_rules_file / write_node_rules_file request. Returns status pending|in_progress|done|failed|timeout, the file name the node used, and (for reads) the content. app#225.",
4220
+ "Poll a read_node_rules_file / write_node_rules_file / node-file / node-log request. Returns status pending|in_progress|done|failed|timeout plus the file name and (reads) content. app#225.",
4221
4221
  { request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
4222
4222
  async ({ request_id: requestId, network_id: clientNetId }) => {
4223
4223
  const effectiveNetId = getNetworkId(clientNetId);
@@ -4298,7 +4298,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4298
4298
 
4299
4299
  server.tool(
4300
4300
  "restart_node",
4301
- "Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
4301
+ "Restart a node without changing config (RFC-024). Network-scoped (SEC-1); member+ suffices — not privilege elevation.",
4302
4302
  {
4303
4303
  ...NODE_ID_ALIAS_FIELDS,
4304
4304
  network_id: z.string().max(200).optional(),
@@ -4930,7 +4930,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4930
4930
  // the policy and the runtime's session capability (see clone-node.ts).
4931
4931
  server.tool(
4932
4932
  "clone_node",
4933
- "Clone a daemon child into a new node on the same daemon. New name and new token. copy_session defaults to true. workdir_policy new_empty (default) or share_source. Hub #938.",
4933
+ "Clone a daemon child into a new node on the same daemon: new name + token. copy_session defaults true; workdir_policy new_empty (default) or share_source. Hub #938.",
4934
4934
  {
4935
4935
  source_node_id: z.string().min(1).max(200),
4936
4936
  name: z.string().min(1).max(256),
@@ -5720,7 +5720,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5720
5720
 
5721
5721
  server.tool(
5722
5722
  "delete_node",
5723
- "Stop child + revoke ntok + delete hub row + (default) backup config to ~/.anet/deleted/<ts>-<alias>/ for 30d. confirm_alias must equal the node's alias. daemon_node_id is auto-resolved when omitted. RFC-027 §2.2.",
5723
+ "Stop child + revoke token + delete hub row + (default) back up config to ~/.anet/deleted/<ts>-<alias>/ for 30d. confirm_alias must match. daemon_node_id auto-resolved. RFC-027 §2.2.",
5724
5724
  {
5725
5725
  ...NODE_ID_ALIAS_FIELDS,
5726
5726
  daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
@@ -6218,7 +6218,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6218
6218
  // §2.3.4 — upsert_network_secret (OWNER-ONLY; vault write).
6219
6219
  server.tool(
6220
6220
  "upsert_network_secret",
6221
- "Write or replace a secret value in the network's vault (AES-GCM encrypted at rest). OWNER-only. RFC-028.",
6221
+ "Create or replace one vault secret by key (value never read back). RFC-028.",
6222
6222
  {
6223
6223
  key: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]{0,63}$/),
6224
6224
  value: z.string().min(1).max(16 * 1024),
@@ -6888,14 +6888,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6888
6888
 
6889
6889
  server.tool(
6890
6890
  "requirements_create",
6891
- "Create a requirement task. name is required. column (or its alias status) = pool / doing / done / abandoned. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
6891
+ "Create a task; name required. column (alias status) = pool/doing/done/abandoned. Duplicate external_ref -> 409 external_ref_exists with existing_id; use requirements_upsert_by_external_ref to sync.",
6892
6892
  { network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
6893
6893
  async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements", args.network_id, pick(a, REQ_WRITE_KEYS))),
6894
6894
  );
6895
6895
 
6896
6896
  server.tool(
6897
6897
  "requirements_update",
6898
- "Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done / abandoned. participants REPLACES the whole list; to add or remove people without dropping the others use participants_add / participants_remove. archived=true hides it from the default list (agents cannot delete).",
6898
+ "Patch a task (id or \"#N\"); omitted fields keep their value. column (alias status) = pool/doing/done/abandoned. participants REPLACES the whole list (use participants_add / _remove to keep others). archived=true hides it; agents cannot delete.",
6899
6899
  { id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, participants_add: reqPeopleDelta("add"), participants_remove: reqPeopleDelta("remove"), external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
6900
6900
  async (args) => withColumn(args, a => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(a, [...REQ_WRITE_KEYS, "participants_add", "participants_remove"]))),
6901
6901
  );
@@ -6909,7 +6909,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6909
6909
 
6910
6910
  server.tool(
6911
6911
  "requirements_upsert_by_external_ref",
6912
- "Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or, if it already exists in the network, patch it (omitted fields — including column / status — are kept). Returns { requirement, created }.",
6912
+ "Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or update the one already bound to it. Returns { requirement, created }.",
6913
6913
  { network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, status: reqStatus, archived: z.boolean().optional() },
6914
6914
  async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(a, REQ_WRITE_KEYS))),
6915
6915
  );
@@ -6926,7 +6926,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6926
6926
  // write access and is refused to 「仅相关任务」(task-scoped) members; events use the task list's visibility.
6927
6927
  server.tool(
6928
6928
  "projects_create",
6929
- "Create a requirement project (管理项目 → 新建). name 1–40, unique; color #RRGGBB; sort int; parent_id optional (≤3 levels). Returns { project }. node tokens can only read projects (403 user_token_required); task-scoped / read-only members are refused.",
6929
+ "Create a project (管理项目 → 新建). name 1-40, unique; color #RRGGBB; sort int; parent_id optional (<=3 levels). Returns { project }; node tokens can only read projects.",
6930
6930
  {
6931
6931
  network_id: z.string().max(200).optional(),
6932
6932
  name: z.string().min(1).max(40),
@@ -6939,7 +6939,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6939
6939
 
6940
6940
  server.tool(
6941
6941
  "projects_update",
6942
- "Rename, recolour, reorder, archive/unarchive, or move a project under another (parent_id; null = top level, cycles / depth>3 refused). Only the fields you pass change; archived hides it from pickers. Returns { project }. node tokens can only read projects (403); task-scoped / read-only members refused.",
6942
+ "Rename, recolour, reorder, archive, or move a project under another (parent_id; null = top level; cycles / depth>3 refused). Only fields you pass change; node tokens can only read projects.",
6943
6943
  {
6944
6944
  network_id: z.string().max(200).optional(),
6945
6945
  id: z.string().min(1).max(200).describe("project id (proj_…), from projects_list"),
@@ -6952,6 +6952,33 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6952
6952
  async (args) => requirementsCall("PATCH", `/api/requirements/projects/${encodeURIComponent(args.id)}`, args.network_id, pick(args, ["name", "color", "sort", "archived", "parent_id"])),
6953
6953
  );
6954
6954
 
6955
+ // #2204 —— 标签管理给 MCP:REST 早就有 rename/merge/delete/color/reparent,但只有人用的 HTTP,
6956
+ // MCP 侧既读不到标签树、也改不了父级。做成**一个**工具(action 分派)而不是 list + op 两个:
6957
+ // tools/list 字节预算是硬约束(all 86,500 / node 64,500 / user 60,500),两个工具会撑破预算,
6958
+ // 而"抬预算"是仓里明令禁止的做法(见 tool-audience-http.test.ts 注释与 #1894 先例)。
6959
+ server.tool(
6960
+ "requirement_tags",
6961
+ "Tags of this network. action=list → name, parent_id (null=root), depth, colour, usage, can_manage. Writes (owner/admin; node tokens 403): rename|merge|delete|color|reparent. reparent moves only the tree edge (cycle / depth>4 refused).",
6962
+ {
6963
+ network_id: z.string().max(200).optional(),
6964
+ action: z.enum(["list", "rename", "merge", "delete", "color", "reparent"]),
6965
+ tag: z.string().min(1).max(20).optional(),
6966
+ from: z.union([z.string().min(1).max(20), z.array(z.string().min(1).max(20)).min(1).max(50)]).optional(),
6967
+ to: z.string().min(1).max(20).optional(),
6968
+ parent: z.string().min(1).max(20).nullable().optional(),
6969
+ color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
6970
+ },
6971
+ async (args) => {
6972
+ if (args.action === "list") return requirementsCall("GET", "/api/requirements/tags", args.network_id);
6973
+ const body: Record<string, unknown> = { op: args.action };
6974
+ if (args.action === "rename" || args.action === "merge") { body.from = args.from; body.to = args.to; }
6975
+ else if (args.action === "delete") body.tag = args.tag;
6976
+ else if (args.action === "color") { body.tag = args.tag; body.color = args.color === undefined ? null : args.color; }
6977
+ else { body.tag = args.tag; if ("parent" in args) body.parent = args.parent; }
6978
+ return requirementsCall("POST", "/api/requirements/tags/ops", args.network_id, body);
6979
+ },
6980
+ );
6981
+
6955
6982
  // #476 —— 不带 requirement_id 的全网动态默认 50 条(REST 默认 200 不变,App 走 REST);严格参数同 #471。
6956
6983
  const REQ_EVENTS_NETWORK_DEFAULT_LIMIT = 50;
6957
6984
  const reqEventsShape = {
@@ -6995,7 +7022,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6995
7022
  // 两次调用,并发时覆盖别人刚写的描述。这条只追加,进「动态」(requirements_events kind=comment),不改任务本身。
6996
7023
  server.tool(
6997
7024
  "requirements_comment",
6998
- "Add a comment / progress note to a task (append-only; never rewrites the description). It shows in the task's activity timeline (the app's 动态, requirements_events kind=comment, text in new.text) under your name. text: markdown, 1–4000 characters. Anyone who can see the task and can write in the network (not read-only) may comment. Returns { event }.",
7025
+ "Append a comment / progress note to a task (**append-only**; never rewrites the description). Shows in requirements_events (kind=comment, text in new.text). text: markdown 1-4000. Anyone who can see the task and write in the network may comment. Returns { event }.",
6999
7026
  {
7000
7027
  id: z.string().min(1).max(200).describe(REQ_ID_DESC),
7001
7028
  text: z.string().min(1).max(4000).describe("the comment (markdown), 1–4000 characters"),