@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 +1 -1
- package/src/node-permissions.ts +1 -0
- package/src/requirement-tag-ops-http.test.ts +159 -0
- package/src/requirement-tag-tree.ts +30 -0
- package/src/requirement-tags.ts +14 -1
- package/src/requirements.ts +29 -4
- package/src/tool-audience-http.test.ts +6 -0
- package/src/tool-audience.ts +1 -1
- package/src/tools.ts +48 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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",
|
package/src/node-permissions.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/requirement-tags.ts
CHANGED
|
@@ -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
|
}
|
package/src/requirements.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/tool-audience.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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"),
|