@sleep2agi/commhub-server 0.9.0-preview.90 → 0.9.0-preview.92

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.
@@ -7,10 +7,13 @@
7
7
  // 能改:负责人是我 / 我建的 / 项目授权 can_edit;能删:负责人是我 / 我建的;不能管项目。
8
8
  // viewer 只看 ④,什么都不能改。
9
9
  // owner/admin 角色与 Hub 管理员不看这一列。节点令牌(Agent)不经过这里 —— 规则原样不动。
10
+ // RFC-040(#455)再加两个来源,和上面的取并集、只加不减:部门项目授权(授权给我的部门或它的上级 ⇒ 同按人授权),
11
+ // 负责人权力(我负责的部门子树里的卡:可看 / 可改 / 可删;viewer 当负责人不算)。见 department-heads.ts。
10
12
  //
11
13
  // 判定是 fail-closed 的:task_access 只有字面 'all' 才放开;role 只有 owner/admin 才豁免。
12
14
 
13
15
  import { db } from "./db.js";
16
+ import { deleteDepartmentGrantsForProject, departmentGrantsForMember, headCardsFor, isDepartmentCard, type DepartmentCards } from "./department-heads.js";
14
17
 
15
18
  // ── 表结构(放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次) ──
16
19
  // ALTER 的 DEFAULT 'all' 让**升级前已有的**成员行全部落在旧语义 —— 升级不收窄任何人;新成员按下面的
@@ -96,7 +99,7 @@ export function listProjectGrants(networkId: string, userId: string): ProjectGra
96
99
  const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
97
100
  export const userRefPattern = (userId: string) => `%${likeEscape(`"kind":"user","id":"${userId}"`)}%`;
98
101
 
99
- type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null };
102
+ type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null; parent_id?: string | null };
100
103
  const refIs = (json: string | null, userId: string) => !!json && json.includes(`"kind":"user","id":"${userId}"`);
101
104
  const ownsCard = (row: CardRow, userId: string) => refIs(row.owner_json, userId);
102
105
  const createdCard = (row: CardRow, userId: string) => refIs(row.created_by_json, userId) || (!row.created_by_json && row.created_by === userId);
@@ -111,26 +114,85 @@ function projectGrant(networkId: string, userId: string, projectId: string | nul
111
114
  return row ? { project_id: projectId, can_edit: row.can_edit === 1 } : null;
112
115
  }
113
116
 
114
- export type TaskCaller = { userId: string } | null;
117
+ /** 部门项目授权(RFC-040):null = 部门没授权这个项目;否则 = can_edit。 */
118
+ function departmentGrant(networkId: string, userId: string, projectId: string | null): boolean | null {
119
+ if (!projectId) return null;
120
+ const grants = departmentGrantsForMember(networkId, userId);
121
+ return grants.has(projectId) ? grants.get(projectId)! : null;
122
+ }
123
+ /** 这张卡是不是我负责的部门(含下级)的卡。 */
124
+ function headOf(userId: string, row: CardRow): boolean {
125
+ const cards = headCardsFor(row.network_id, userId);
126
+ return !!cards && isDepartmentCard(cards, row);
127
+ }
128
+
129
+ /**
130
+ * 调用者。userId = 按谁的任务权限判;node(RFC-041,#487)= 节点令牌按**主人**的权限判时带上节点:
131
+ * only=false(正常模式、执行开关打开)—— 主人能改的能改,另外「派给这个节点的」在主人看得见时也能改;
132
+ * only=true (受限模式)—— 只看、只改派给这个节点的卡(负责 Agent / 参与人 / 它建的,及这些卡的子任务)。
133
+ */
134
+ export type TaskCaller = { userId: string; node?: { id: string; only: boolean } } | null;
135
+
136
+ export const nodeRefPattern = (nodeId: string) => `%${likeEscape(`"kind":"node","id":"${nodeId}"`)}%`;
137
+ const nodeRefIs = (json: string | null | undefined, nodeId: string) => !!json && json.includes(`"kind":"node","id":"${nodeId}"`);
138
+ const directlyAssigned = (row: Pick<CardRow, "agent_owner_json" | "participants_json" | "created_by_json">, nodeId: string) =>
139
+ nodeRefIs(row.agent_owner_json, nodeId) || nodeRefIs(row.participants_json, nodeId) || nodeRefIs(row.created_by_json, nodeId);
140
+
141
+ /** 这张卡派给了这个节点吗:负责 Agent / 参与人有它 / 它建的,或者父卡是。 */
142
+ export function cardAssignedToNode(row: CardRow, nodeId: string): boolean {
143
+ if (directlyAssigned(row, nodeId)) return true;
144
+ if (!row.parent_id) return false;
145
+ const parent = db.get<{ agent_owner_json: string | null; participants_json: string | null; created_by_json: string | null }>(
146
+ "SELECT agent_owner_json, participants_json, created_by_json FROM requirements WHERE requirement_id = ?1", row.parent_id);
147
+ return !!parent && directlyAssigned(parent, nodeId);
148
+ }
115
149
 
116
150
  /** 看得见这张卡吗。caller=null(节点令牌 / 管理员 / 旧的全局令牌)恒为 true。 */
117
151
  export function canSeeTask(caller: TaskCaller, row: CardRow): boolean {
152
+ if (caller?.node?.only && !cardAssignedToNode(row, caller.node.id)) return false;
153
+ return canSeeTaskAsUser(caller, row);
154
+ }
155
+ function canSeeTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
118
156
  if (!caller) return true;
119
157
  const m = memberRow(caller.userId, row.network_id);
120
158
  if (!scopedRow(m)) return true;
121
159
  if (projectGrant(row.network_id, caller.userId, row.project_id)) return true;
160
+ if (departmentGrant(row.network_id, caller.userId, row.project_id) !== null) return true;
122
161
  if (m?.member_role === "viewer") return false;
123
- return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || participates(row, caller.userId);
162
+ return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || participates(row, caller.userId) || headOf(caller.userId, row);
124
163
  }
125
164
 
126
165
  /** 能改这张卡吗(PATCH / 勾子任务)。只对看得见的卡调用。 */
127
166
  export function canEditTask(caller: TaskCaller, row: CardRow): boolean {
167
+ if (caller?.node) {
168
+ const assigned = cardAssignedToNode(row, caller.node.id) && canSeeTaskAsUser(caller, row);
169
+ return caller.node.only ? assigned : assigned || canEditTaskAsUser(caller, row);
170
+ }
171
+ return canEditTaskAsUser(caller, row);
172
+ }
173
+ function canEditTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
128
174
  if (!caller) return true;
129
175
  const m = memberRow(caller.userId, row.network_id);
130
176
  if (!scopedRow(m)) return true;
131
177
  if (m?.member_role === "viewer") return false;
132
178
  if (ownsCard(row, caller.userId) || createdCard(row, caller.userId)) return true;
133
- return projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true;
179
+ if (projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true) return true;
180
+ return departmentGrant(row.network_id, caller.userId, row.project_id) === true || headOf(caller.userId, row);
181
+ }
182
+
183
+ /**
184
+ * 这次能改,是不是**只**因为负责人身份(没有自己的 / 按人或部门授权的来源)。这种时候负责人只能把卡交给本部门的人
185
+ * (RFC-040 §1:改负责人只能改成本部门成员 / 其 Agent / 自己),返回本部门的人和节点;否则 null(不加限制)。
186
+ */
187
+ export function headOnlyEditScope(caller: TaskCaller, row: CardRow): DepartmentCards | null {
188
+ if (!caller) return null;
189
+ const m = memberRow(caller.userId, row.network_id);
190
+ if (!scopedRow(m) || m?.member_role === "viewer") return null;
191
+ if (ownsCard(row, caller.userId) || createdCard(row, caller.userId)) return null;
192
+ if (projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true) return null;
193
+ if (departmentGrant(row.network_id, caller.userId, row.project_id) === true) return null;
194
+ const cards = headCardsFor(row.network_id, caller.userId);
195
+ return cards && isDepartmentCard(cards, row) ? cards : null;
134
196
  }
135
197
 
136
198
  /**
@@ -142,7 +204,7 @@ export const PARTICIPANT_EDIT_FIELDS: readonly string[] = ["column", "checklist"
142
204
 
143
205
  /** 这个调用者是不是「只能以参与人身份改」这张卡(canEditTask 为 false、但他是卡上的参与人)。viewer 不算。 */
144
206
  export function canParticipantEditTask(caller: TaskCaller, row: CardRow): boolean {
145
- if (!caller) return false;
207
+ if (!caller || caller.node) return false; // 参与人身份是人的;节点按 canEditTask 判
146
208
  const m = memberRow(caller.userId, row.network_id);
147
209
  if (!scopedRow(m) || m?.member_role === "viewer") return false;
148
210
  return participates(row, caller.userId);
@@ -159,7 +221,15 @@ export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
159
221
  const m = memberRow(caller.userId, row.network_id);
160
222
  if (!scopedRow(m)) return true;
161
223
  if (m?.member_role === "viewer") return false;
162
- return ownsCard(row, caller.userId) || createdCard(row, caller.userId);
224
+ return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || headOf(caller.userId, row);
225
+ }
226
+
227
+ /** 这次能删只因为负责人身份(RFC-040 Q4:记 requirement_deleted_by_leader + 私信卡的负责人)。 */
228
+ export function deletesAsHeadOnly(caller: TaskCaller, row: CardRow): boolean {
229
+ if (!caller) return false;
230
+ const m = memberRow(caller.userId, row.network_id);
231
+ if (!scopedRow(m) || m?.member_role === "viewer") return false;
232
+ return !ownsCard(row, caller.userId) && !createdCard(row, caller.userId) && headOf(caller.userId, row);
163
233
  }
164
234
 
165
235
  /**
@@ -170,14 +240,16 @@ export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
170
240
  export type TaskPermissions = { edit: boolean; delete: boolean; edit_fields?: readonly string[] };
171
241
  export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => TaskPermissions | null {
172
242
  if (!caller) return () => null;
173
- const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean> }>();
243
+ const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean>; deptGrants: Map<string, boolean>; head: DepartmentCards | null }>();
174
244
  const ctxFor = (networkId: string) => {
175
245
  let c = cache.get(networkId);
176
246
  if (!c) {
177
247
  const m = memberRow(caller.userId, networkId);
178
248
  const scoped = scopedRow(m);
249
+ const viewer = m?.member_role === "viewer";
179
250
  const grants = new Map<string, boolean>(scoped ? listProjectGrants(networkId, caller.userId).map((g) => [g.project_id, g.can_edit] as [string, boolean]) : []);
180
- cache.set(networkId, c = { scoped, viewer: m?.member_role === "viewer", grants });
251
+ const deptGrants = scoped ? departmentGrantsForMember(networkId, caller.userId) : new Map<string, boolean>();
252
+ cache.set(networkId, c = { scoped, viewer, grants, deptGrants, head: scoped && !viewer ? headCardsFor(networkId, caller.userId) : null });
181
253
  }
182
254
  return c;
183
255
  };
@@ -185,8 +257,9 @@ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => T
185
257
  const c = ctxFor(row.network_id);
186
258
  if (!c.scoped) return null;
187
259
  if (c.viewer) return { edit: false, delete: false };
188
- const mine = ownsCard(row, caller.userId) || createdCard(row, caller.userId);
189
- const edit = mine || (row.project_id ? c.grants.get(row.project_id) === true : false);
260
+ const head = !!c.head && isDepartmentCard(c.head, row);
261
+ const mine = ownsCard(row, caller.userId) || createdCard(row, caller.userId) || head;
262
+ const edit = mine || (row.project_id ? c.grants.get(row.project_id) === true || c.deptGrants.get(row.project_id) === true : false);
190
263
  if (!edit && participates(row, caller.userId)) return { edit, delete: mine, edit_fields: PARTICIPANT_EDIT_FIELDS };
191
264
  return { edit, delete: mine };
192
265
  };
@@ -199,6 +272,7 @@ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => T
199
272
  export function projectUseResolver(caller: TaskCaller, networkId: string): (projectId: string) => boolean {
200
273
  if (!caller || !isTaskScoped(caller.userId, networkId)) return () => true;
201
274
  const editable = new Set(listProjectGrants(networkId, caller.userId).filter((g) => g.can_edit).map((g) => g.project_id));
275
+ for (const [projectId, canEdit] of departmentGrantsForMember(networkId, caller.userId)) if (canEdit) editable.add(projectId);
202
276
  return (projectId) => editable.has(projectId);
203
277
  }
204
278
 
@@ -206,7 +280,7 @@ export function projectUseResolver(caller: TaskCaller, networkId: string): (proj
206
280
  export function canUseProject(caller: TaskCaller, networkId: string, projectId: string | null): boolean {
207
281
  if (!caller || !projectId) return true;
208
282
  if (!isTaskScoped(caller.userId, networkId)) return true;
209
- return projectGrant(networkId, caller.userId, projectId)?.can_edit === true;
283
+ return projectGrant(networkId, caller.userId, projectId)?.can_edit === true || departmentGrant(networkId, caller.userId, projectId) === true;
210
284
  }
211
285
 
212
286
  /**
@@ -214,6 +288,14 @@ export function canUseProject(caller: TaskCaller, networkId: string, projectId:
214
288
  * 追加在已经按网络过滤过的 `FROM requirements WHERE …` 后面。
215
289
  */
216
290
  export function addTaskVisibilityScope(sql: string, params: unknown[], caller: TaskCaller): string {
291
+ const scoped = addTaskVisibilityScopeAsUser(sql, params, caller);
292
+ if (!caller?.node?.only) return scoped;
293
+ // 受限节点:再只留派给它的卡(及其子任务)。
294
+ const pat = params.push(nodeRefPattern(caller.node.id));
295
+ const mine = (t: string) => `${t}agent_owner_json LIKE ?${pat} ESCAPE '\\' OR ${t}participants_json LIKE ?${pat} ESCAPE '\\' OR ${t}created_by_json LIKE ?${pat} ESCAPE '\\'`;
296
+ return `${scoped} AND (${mine("")} OR parent_id IN (SELECT p.requirement_id FROM requirements p WHERE ${mine("p.")}))`;
297
+ }
298
+ function addTaskVisibilityScopeAsUser(sql: string, params: unknown[], caller: TaskCaller): string {
217
299
  if (!caller) return sql;
218
300
  const scoped = taskScopedNetworks(caller.userId);
219
301
  if (!scoped.length) return sql;
@@ -232,6 +314,16 @@ export function addTaskVisibilityScope(sql: string, params: unknown[], caller: T
232
314
  parts.push(`(network_id IN (${ph(members)}) AND (${mine} OR ${granted}))`);
233
315
  }
234
316
  if (viewers.length) parts.push(`(network_id IN (${ph(viewers)}) AND ${granted})`);
317
+ // RFC-040:部门项目授权(viewer 也有,和按人授权一样只给「看」)+ 负责人本部门的卡(viewer 没有)。只加析取项。
318
+ for (const s of scoped) {
319
+ const extra: string[] = [];
320
+ const deptProjects = [...departmentGrantsForMember(s.networkId, caller.userId).keys()];
321
+ if (deptProjects.length) extra.push(`project_id IN (${ph(deptProjects)})`);
322
+ const head = s.viewer ? null : headCardsFor(s.networkId, caller.userId);
323
+ if (head?.users.size) extra.push(`owner_json IN (${ph([...head.users].map((id) => JSON.stringify({ kind: "user", id })))})`);
324
+ if (head?.nodes.size) extra.push(`agent_owner_json IN (${ph([...head.nodes].map((id) => JSON.stringify({ kind: "node", id })))})`);
325
+ if (extra.length) parts.push(`(network_id = ?${params.push(s.networkId)} AND (${extra.join(" OR ")}))`);
326
+ }
235
327
  return `${sql} AND (${parts.join(" OR ")})`;
236
328
  }
237
329
 
@@ -240,7 +332,10 @@ export function addProjectVisibilityScope(sql: string, params: unknown[], caller
240
332
  if (!caller || !isTaskScoped(caller.userId, networkId)) return sql;
241
333
  const uid = params.push(caller.userId);
242
334
  const net = params.push(networkId);
243
- return `${sql} AND project_id IN (SELECT project_id FROM network_member_project_grants WHERE user_id = ?${uid} AND network_id = ?${net})`;
335
+ const personal = `project_id IN (SELECT project_id FROM network_member_project_grants WHERE user_id = ?${uid} AND network_id = ?${net})`;
336
+ const deptProjects = [...departmentGrantsForMember(networkId, caller.userId).keys()];
337
+ if (!deptProjects.length) return `${sql} AND ${personal}`;
338
+ return `${sql} AND (${personal} OR project_id IN (${deptProjects.map((id) => `?${params.push(id)}`).join(", ")}))`;
244
339
  }
245
340
 
246
341
  export type ReplaceTaskGrantsResult =
@@ -302,6 +397,7 @@ export function deleteTaskGrantsForMember(networkId: string, userId: string): vo
302
397
  }
303
398
  export function deleteTaskGrantsForProject(projectId: string): void {
304
399
  db.run("DELETE FROM network_member_project_grants WHERE project_id = ?1", [projectId]);
400
+ deleteDepartmentGrantsForProject(projectId);
305
401
  }
306
402
 
307
403
  // 被拒的写入审计限频:同一 (用户, 卡) 每小时最多记一条,别让重试刷爆审计表。
package/src/tools.ts CHANGED
@@ -12,7 +12,8 @@ import { assertNodeActive } from "./lifecycle-guard.js";
12
12
  import { pendingInboxCount } from "./inbox-count.js";
13
13
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
14
14
  import { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
15
- import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
15
+ import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
16
+ import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
16
17
  import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
17
18
  import { errorBody } from "./requirements-errors.js";
18
19
  import { handleRequirementsRequest } from "./requirements.js";
@@ -148,9 +149,9 @@ function scheduledReplyRecipient(taskId: string, networkId: string | null | unde
148
149
  // 方无影响,只是错误形态更一致)。
149
150
  const NODE_ID_ALIAS_FIELDS = {
150
151
  node_id: z.string().min(1).max(200).optional()
151
- .describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
152
+ .describe("Child node id (as returned by create_node), in your network."),
152
153
  child_node_id: z.string().min(1).max(200).optional()
153
- .describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
154
+ .describe("Deprecated alias of node_id."),
154
155
  } as const;
155
156
 
156
157
  /** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
@@ -200,11 +201,69 @@ function guardRestrictedMemberTools(server: McpServer, restrictedNets: string[],
200
201
  }
201
202
  }
202
203
 
204
+ // RFC-041 第一阶段(#487):节点令牌的每个 MCP 工具先按 NODE_TOOL_CLASS 判一次(node-permissions.ts)。
205
+ // log 模式下正常节点照常放行、只记录;显式的只读 / 受限模式立刻生效。requirements_* 在 requirements.ts 里按卡判。
206
+ function guardNodePermissionTools(server: McpServer, tokenId: string, networkId: string): void {
207
+ const denied = (reason: Parameters<typeof nodePermissionDeniedBody>[0], route: string) => ({
208
+ content: [{ type: "text" as const, text: JSON.stringify(nodePermissionDeniedBody(reason, route)) }],
209
+ });
210
+ const taskTarget = (taskId: unknown): { to: AgentRef; from: string | null } | null => {
211
+ if (typeof taskId !== "string" || !taskId) return null;
212
+ const t = db.get<{ to_name: string | null; to_node_id: string | null; from_name: string | null }>(
213
+ "SELECT to_name, to_node_id, from_name FROM tasks WHERE task_id = ?1 AND network_id = ?2", taskId, networkId);
214
+ return t ? { to: { alias: t.to_name, nodeId: t.to_node_id }, from: t.from_name } : null;
215
+ };
216
+ const aliasTarget = (alias: unknown): AgentRef | null => {
217
+ if (typeof alias !== "string" || !alias.trim()) return null;
218
+ const canonical = resolveCanonicalAlias(networkId, alias.trim()).alias;
219
+ const session = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", canonical, networkId);
220
+ return { alias: canonical, nodeId: session?.node_id ?? null };
221
+ };
222
+ const verdictFor = (id: NodeIdentity, name: string, args: Record<string, unknown>): Verdict => {
223
+ switch (NODE_TOOL_CLASS[name]) {
224
+ case "dispatch": {
225
+ if (name === "retry_task" || name === "cancel_task") {
226
+ const t = taskTarget(args.task_id);
227
+ if (!t) return null; // 没这个任务:交给工具自己回 not found
228
+ if (name === "cancel_task" && t.from && id.aliases.includes(t.from)) return null; // 撤回自己派的
229
+ return dispatchVerdict(id, t.to);
230
+ }
231
+ const target = aliasTarget(name === "reassign_task" ? args.new_alias : args.alias);
232
+ return target ? dispatchVerdict(id, target) : null;
233
+ }
234
+ case "broadcast": return broadcastVerdict(id);
235
+ case "node_write": return writeVerdict(id, name);
236
+ case "human_only": return humanOnlyVerdict(id, name);
237
+ default: return null; // always / read / requirements(按卡判)/ 没归类的(test 保证没有)
238
+ }
239
+ };
240
+ const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
241
+ const cls = NODE_TOOL_CLASS[name];
242
+ if (cls && cls !== "always" && cls !== "read" && cls !== "requirements") {
243
+ const id = nodeIdentity(tokenId, networkId);
244
+ const args = callArgs[0] && typeof callArgs[0] === "object" ? callArgs[0] as Record<string, unknown> : {};
245
+ const verdict = id ? verdictFor(id, name, args) : null;
246
+ if (id && nodeDecide(id, `mcp:${name}`, verdict)) return denied(verdict!.reason, `mcp:${name}`);
247
+ }
248
+ return handler(...callArgs);
249
+ };
250
+ const anyServer = server as any;
251
+ for (const method of ["tool", "registerTool"] as const) {
252
+ const original = anyServer[method].bind(server);
253
+ anyServer[method] = (...regArgs: any[]) => {
254
+ const last = regArgs.length - 1;
255
+ if (typeof regArgs[0] === "string" && typeof regArgs[last] === "function") regArgs[last] = wrap(regArgs[0], regArgs[last]);
256
+ return original(...regArgs);
257
+ };
258
+ }
259
+ }
260
+
203
261
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
204
262
  // 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
205
263
  // 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
206
264
  const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
207
265
  if (restrictedNets.length) guardRestrictedMemberTools(server, restrictedNets, enforceUserId!);
266
+ if (callerTokenIsNetwork && callerTokenId && enforceNetworkId) guardNodePermissionTools(server, callerTokenId, enforceNetworkId);
208
267
  // Default from_session for outbound tools — extracted from the calling
209
268
  // token's binding (ntok_ → node alias, utok_ → username). Without this,
210
269
  // an agent's send_task call always claimed from='hub' and peer agents
@@ -1639,7 +1698,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1639
1698
  network_id: z.string().max(200).optional().describe("Network scope"),
1640
1699
  parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of. When the child task replies the hub will auto-chain the answer to the parent task's originator, so the user sees the final result even if the intermediate session ends."),
1641
1700
  meta: z.any().optional().describe("Optional structured task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
1642
- force: z.boolean().optional().describe("#460 — dispatch even when the target reports a degraded health layer (user tokens only; ignored for node tokens)"),
1701
+ force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
1643
1702
  },
1644
1703
  async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1645
1704
  const effectiveNetId = getNetworkId(netId);
@@ -1972,7 +2031,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1972
2031
  // `attachments` before this schema entry existed would see `ok:true`
1973
2032
  // and never learn the attachments were dropped. Validated by
1974
2033
  // validateAttachments (uploads.ts) — the same helper the REST path uses.
1975
- attachments: z.any().optional().describe("Optional attachment array; parity with send_task's meta.attachments. Each item: {type:'file', file_id, name?, mime?, size?}. Persisted into tasks.meta_json + inbox.meta_json."),
2034
+ attachments: z.any().optional().describe("Attachments, like send_task's meta.attachments: [{type:'file', file_id, name?, mime?, size?}]."),
1976
2035
  // #507 — optional structured metadata (parity with send_task L837). If
1977
2036
  // both `attachments` (top-level) and `meta.attachments` are supplied,
1978
2037
  // top-level wins (same rule as REST /api/task L2101).
@@ -2359,7 +2418,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2359
2418
  task_id: z.string().min(1).max(200).describe("Task ID to retry"),
2360
2419
  from_session: z.string().max(200).optional(),
2361
2420
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
2362
- force: z.boolean().optional().describe("#460 — dispatch even when the target reports a degraded health layer (user tokens only; ignored for node tokens)"),
2421
+ force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
2363
2422
  },
2364
2423
  async ({ task_id, from_session: _fromIn, network_id: netId, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2365
2424
  const effectiveNetId = getNetworkId(netId);
@@ -2578,7 +2637,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2578
2637
  new_alias: z.string().min(1).max(200).describe("Target agent alias"),
2579
2638
  from_session: z.string().max(200).optional(),
2580
2639
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
2581
- force: z.boolean().optional().describe("#460 — dispatch even when the target reports a degraded health layer (user tokens only; ignored for node tokens)"),
2640
+ force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
2582
2641
  },
2583
2642
  async ({ task_id, new_alias, from_session: _fromIn, network_id: netId, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2584
2643
  const effectiveNetId = getNetworkId(netId);
@@ -3509,7 +3568,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3509
3568
  "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.",
3510
3569
  {
3511
3570
  ...NODE_ID_ALIAS_FIELDS,
3512
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3571
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
3513
3572
  network_id: z.string().max(200).optional(),
3514
3573
  },
3515
3574
  async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, alias, network_id }),
@@ -3520,7 +3579,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3520
3579
  "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.",
3521
3580
  {
3522
3581
  ...NODE_ID_ALIAS_FIELDS,
3523
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3582
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting rules_file_capable)."),
3524
3583
  content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
3525
3584
  network_id: z.string().max(200).optional(),
3526
3585
  },
@@ -3532,7 +3591,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3532
3591
  "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
3533
3592
  {
3534
3593
  ...NODE_ID_ALIAS_FIELDS,
3535
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3594
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
3536
3595
  network_id: z.string().max(200).optional(),
3537
3596
  },
3538
3597
  async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("skills_list", { node_id, child_node_id, alias, network_id }),
@@ -3543,7 +3602,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3543
3602
  "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}.",
3544
3603
  {
3545
3604
  ...NODE_ID_ALIAS_FIELDS,
3546
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3605
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
3547
3606
  name: z.string().min(1).max(64).describe("Skill directory name, [A-Za-z0-9._-]."),
3548
3607
  network_id: z.string().max(200).optional(),
3549
3608
  },
@@ -3555,7 +3614,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3555
3614
  "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.",
3556
3615
  {
3557
3616
  ...NODE_ID_ALIAS_FIELDS,
3558
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
3617
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
3559
3618
  path: z.string().max(NODE_FILE_PATH_MAX).optional().describe("Directory relative to the node's work dir; omit or \"\" for the root."),
3560
3619
  network_id: z.string().max(200).optional(),
3561
3620
  },
@@ -3567,7 +3626,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3567
3626
  "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.",
3568
3627
  {
3569
3628
  ...NODE_ID_ALIAS_FIELDS,
3570
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
3629
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting files_capable)."),
3571
3630
  path: z.string().min(1).max(NODE_FILE_PATH_MAX).describe("File path relative to the node's work dir."),
3572
3631
  network_id: z.string().max(200).optional(),
3573
3632
  },
@@ -3579,7 +3638,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3579
3638
  "Ask a node for the tail of its own agent-node run log (read-only, redacted on the node before it leaves). No path argument: the node reads only its own log directory. User logins only; the node's owner or a network owner/admin. Poll get_rules_file_result — content is JSON {files, lines:[{ts, level, text, key}], truncated, matched, now_ts}; it is handed out once and then purged.",
3580
3639
  {
3581
3640
  ...NODE_ID_ALIAS_FIELDS,
3582
- alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported logs_capable."),
3641
+ alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting logs_capable)."),
3583
3642
  lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
3584
3643
  level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
3585
3644
  grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
@@ -5844,9 +5903,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5844
5903
  // (requirements.ts personRef;找不到 / 别名对上多个 → 400 + hint)。给了 id 就只看 id。
5845
5904
  const reqPerson = z.object({
5846
5905
  kind: z.enum(["user", "node"]),
5847
- id: z.string().min(1).max(200).optional().describe("user_id / node_id (from requirements_people)"),
5848
- username: z.string().min(1).max(100).optional().describe("kind 'user' only: the member's username, resolved in the task's network"),
5849
- alias: z.string().min(1).max(200).optional().describe("kind 'node' only: the Agent's alias, resolved in the task's network"),
5906
+ // #476 —— 这三格的说明不写在这里:reqPerson 在 3 个工具 × 3 个字段里展开,每份都复制一遍(~1.6 KB)。
5907
+ // 写法在 owner / agent_owner / participants 的 describe 里各说一次。
5908
+ id: z.string().min(1).max(200).optional(),
5909
+ username: z.string().min(1).max(100).optional(),
5910
+ alias: z.string().min(1).max(200).optional(),
5850
5911
  });
5851
5912
  const reqChecklistItem = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/).optional(), text: z.string().min(1).max(500), done: z.boolean().optional() });
5852
5913
  const reqFields = {
@@ -5859,9 +5920,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5859
5920
  description: z.string().max(20_000).optional().describe("markdown"),
5860
5921
  checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
5861
5922
  project_id: z.string().max(200).nullable().optional(),
5862
- owner: reqPerson.nullable().optional().describe("负责人 (a person): {kind:'user', id} or {kind:'user', username}; a node here is rejected with 400 owner_must_be_human — use agent_owner for nodes"),
5863
- agent_owner: reqPerson.nullable().optional().describe("负责 Agent: {kind:'node', id} or {kind:'node', alias}"),
5864
- participants: z.array(reqPerson).max(100).optional().describe("REPLACES the whole participant list ({kind, id}, {kind:'user', username} or {kind:'node', alias}) (omitted people are removed; [] clears it). To add or remove a few people without touching the others, use participants_add / participants_remove on requirements_update."),
5923
+ owner: reqPerson.nullable().optional().describe("负责人 (a person): {kind:'user', id | username} (ids / usernames from requirements_people; names resolve in the task's network); a node is rejected with owner_must_be_human — use agent_owner"),
5924
+ agent_owner: reqPerson.nullable().optional().describe("负责 Agent: {kind:'node', id | alias}"),
5925
+ participants: z.array(reqPerson).max(100).optional().describe("REPLACES the whole list (written like owner / agent_owner; omitted people are removed, [] clears). To add or remove a few, use participants_add / participants_remove on requirements_update."),
5865
5926
  external_url: z.string().max(500).nullable().optional(),
5866
5927
  parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
5867
5928
  };
@@ -5869,14 +5930,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5869
5930
  const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
5870
5931
  // #475 —— status 是 column 的别名(列表的筛选参数就叫 status,Agent 写任务时也常这么写)。只在 MCP 这层换名,REST 不变;
5871
5932
  // 两个都给且不同 → 400 status_conflicts_with_column,不替调用方挑一个。
5872
- const reqStatus = z.enum(["pool", "doing", "done"]).optional().describe("alias of column (same values); if both are sent they must match");
5933
+ const reqStatus = z.enum(["pool", "doing", "done"]).optional().describe("alias of column; must match it if both are sent");
5873
5934
  const withColumn = (args: Record<string, unknown>, call: (args: Record<string, unknown>) => Promise<{ content: { type: "text"; text: string }[] }>) => {
5874
5935
  if (args.status === undefined) return call(args);
5875
5936
  if (args.column !== undefined && args.column !== args.status) return Promise.resolve({ content: [{ type: "text" as const, text: JSON.stringify({ ...errorBody("status_conflicts_with_column"), status: 400 }) }] });
5876
5937
  return call({ ...args, column: args.status });
5877
5938
  };
5878
- const reqPeopleDelta = (verb: string) => z.array(reqPerson).max(100).optional().describe(`${verb} these people ${verb === "add" ? "to" : "from"} the participant list, keeping everyone else (atomic; ${verb === "add" ? "already present = no change" : "not present = no change"}). Cannot be combined with participants.`);
5879
- const REQ_ID_DESC = "requirement id (req_…) or its short number \"#N\" (e.g. \"#42\", per network; a user token that sees several networks must also pass network_id, else 409 ambiguous_seq)";
5939
+ const reqPeopleDelta = (verb: string) => z.array(reqPerson).max(100).optional().describe(`${verb === "add" ? "Add to" : "Remove from"} the participant list, keeping the rest (atomic; not with participants).`);
5940
+ const REQ_ID_DESC = "requirement id (req_…) or short number \"#N\" (per network; with several networks also pass network_id)";
5880
5941
 
5881
5942
  // #471 —— MCP 的列表默认省流:summary 视图 + 50 张一页(REST 的默认 full + 500 不变,App 靠它)。
5882
5943
  // 生产实测 122 张卡:旧默认(full、500)148 KB,summary 81 KB —— 一次调用就塞满 Agent 的上下文。
@@ -5894,6 +5955,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5894
5955
  external_ref: z.string().max(200).optional(),
5895
5956
  include_archived: z.boolean().optional(),
5896
5957
  parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5958
+ department_id: z.string().max(64).optional().describe("owner (or its Agent's owner) in this department or below"),
5897
5959
  top_level: z.boolean().optional(), q: z.string().max(200).optional(),
5898
5960
  limit: z.number().int().min(1).max(1000).optional().describe(`page size, default ${REQ_LIST_MCP_DEFAULT_LIMIT}`),
5899
5961
  cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
@@ -5904,7 +5966,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5904
5966
  server.registerTool(
5905
5967
  "requirements_list",
5906
5968
  {
5907
- description: `List requirement tasks in your network. Defaults (MCP): view='summary' and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page, newest first — about 0.5 KB per task, so a page stays small. has_more + next_cursor → pass cursor for the next page. Summary rows drop description and checklist bodies (has_description, checklist_count instead); for full text read one task with requirements_get, or pass view='full' (several KB per task — keep limit small). Filters: seq (the per-network short number shown as #N), status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact label), updated_since (ISO), external_ref, parent_id / top_level, include_archived; q = search title / description / owner, agent and participant names / project name / tags (space-separated terms are ANDed). limit ≤ 1000. Each task carries children {total, done}. changes=true (needs updated_since) returns only tasks changed since then, archived ones included, plus deleted ids and server_time to pass as the next updated_since. Unknown parameters are rejected (-32602) with the list of valid ones.`,
5969
+ description: `List requirement tasks in your network, newest first. Defaults (MCP): view='summary' (no description / checklist bodies; has_description, checklist_count instead; ~0.5 KB per task) and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page; has_more + next_cursor → pass cursor. Full text: requirements_get, or view='full' with a small limit (≤ 1000). Filters: seq (#N), status, project_id ('none'), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact), updated_since (ISO), external_ref, parent_id / top_level, department_id (owner in it or below), include_archived; q searches title / description / people / project / tags (terms ANDed). Rows carry children {total, done}. changes=true (with updated_since) returns only tasks changed since then, archived included, plus deleted ids and server_time for the next updated_since. Unknown parameters are rejected (-32602).`,
5908
5970
  // Strict: registerTool honours a constructed object's unknownKeys (server.tool() would strip them silently).
5909
5971
  inputSchema: z.strictObject(reqListShape, {
5910
5972
  error: (iss: any) => iss.code === "unrecognized_keys"
@@ -5914,7 +5976,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5914
5976
  },
5915
5977
  async (args: any) => {
5916
5978
  const q = new URLSearchParams();
5917
- for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "tag", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor", "view"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5979
+ for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "tag", "updated_since", "external_ref", "parent_id", "department_id", "q", "limit", "cursor", "view"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5918
5980
  if (!q.has("view")) q.set("view", "summary");
5919
5981
  if (!q.has("limit")) q.set("limit", String(REQ_LIST_MCP_DEFAULT_LIMIT));
5920
5982
  if (args.include_archived) q.set("include_archived", "1");
@@ -5935,7 +5997,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5935
5997
  server.registerTool(
5936
5998
  "requirements_people",
5937
5999
  {
5938
- description: "Look up the people in your network and the Agents they own, to fill task person fields. Each row: user_id, username, display_name (\"\" if not set), role, department {id, name} or null, agents [{node_id, alias}]; Agents with no owner in this network are listed in agents_without_owner (first page only). q filters by username / display name / department / Agent alias (case-insensitive substring). 50 people per page (limit ≤ 200); has_more + next_offset → pass offset. Agents you are not granted are left out. Person fields on requirements_create / requirements_update / requirements_upsert_by_external_ref accept {kind:'user', id} or {kind:'user', username} (owner, participants) and {kind:'node', id} or {kind:'node', alias} (agent_owner, participants), resolved in the task's own network only.",
6000
+ description: "Look up the people in your network and the Agents they own, to fill task person fields. Each row: user_id, username, display_name (\"\" if not set), role, department {id, name} or null, agents [{node_id, alias}]; Agents with no owner in this network are listed in agents_without_owner (first page only). q filters by username / display name / department / Agent alias (case-insensitive substring). 50 people per page (limit ≤ 200); has_more + next_offset → pass offset. Agents you are not granted are left out. Task person fields take these ids, or {kind:'user', username} / {kind:'node', alias}.",
5939
6001
  inputSchema: z.strictObject(reqPeopleShape, {
5940
6002
  error: (iss: any) => iss.code === "unrecognized_keys"
5941
6003
  ? `unknown parameter(s): ${(iss.keys ?? []).join(", ")}; valid parameters: ${reqPeopleKeys.join(", ")}`
@@ -6020,17 +6082,27 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6020
6082
  async (args) => requirementsCall("PATCH", `/api/requirements/projects/${encodeURIComponent(args.id)}`, args.network_id, pick(args, ["name", "color", "sort", "archived"])),
6021
6083
  );
6022
6084
 
6023
- server.tool(
6085
+ // #476 —— 不带 requirement_id 的全网动态默认 50 条(REST 默认 200 不变,App 走 REST);严格参数同 #471。
6086
+ const REQ_EVENTS_NETWORK_DEFAULT_LIMIT = 50;
6087
+ const reqEventsShape = {
6088
+ network_id: z.string().max(200).optional(),
6089
+ requirement_id: z.string().min(1).max(200).optional().describe(REQ_ID_DESC),
6090
+ since: z.string().max(40).optional().describe("ISO; only events after it"),
6091
+ limit: z.number().int().min(1).max(500).optional(),
6092
+ cursor: z.string().max(20).optional().describe("next_cursor from the previous page (older events)"),
6093
+ };
6094
+ const reqEventsKeys = Object.keys(reqEventsShape);
6095
+ server.registerTool(
6024
6096
  "requirements_events",
6025
- "Read the task activity timeline (the app's 动态): field-level changes — who, when, old → new — and comments (kind=comment, text in new.text; add one with requirements_comment), newest first. Pass requirement_id (req_… or \"#N\") for one task, or omit it for the whole network. since (ISO) = only changes after it; limit 1–500 (default 200); next_cursor → pass as cursor for the next page; server_time → use as the next since. Same visibility as requirements_list: tasks you can't see (and hidden Agents) are left out.",
6026
6097
  {
6027
- network_id: z.string().max(200).optional(),
6028
- requirement_id: z.string().min(1).max(200).optional(),
6029
- since: z.string().max(40).optional(),
6030
- limit: z.number().int().min(1).max(500).optional(),
6031
- cursor: z.string().max(20).optional(),
6098
+ description: `Read the task activity timeline (the app's 动态), newest first: field-level changes (who, when, old → new) and comments (kind=comment, text in new.text; add one with requirements_comment). With requirement_id (req_… or "#N"): that task's events (limit default 200). Without it: the whole network, ${REQ_EVENTS_NETWORK_DEFAULT_LIMIT} per page by default. limit ≤ 500; next_cursor → pass as cursor for older events; server_time → use as the next since. Same visibility as requirements_list. Unknown parameters are rejected (-32602).`,
6099
+ inputSchema: z.strictObject(reqEventsShape, {
6100
+ error: (iss: any) => iss.code === "unrecognized_keys"
6101
+ ? `unknown parameter(s): ${(iss.keys ?? []).join(", ")}; valid parameters: ${reqEventsKeys.join(", ")}`
6102
+ : undefined,
6103
+ }) as any,
6032
6104
  },
6033
- async (args) => {
6105
+ async (args: any) => {
6034
6106
  let card = args.requirement_id;
6035
6107
  // "#N" → the card's id, resolved exactly like requirements_get (same scope; a card you can't see is a 404 here too).
6036
6108
  if (card && /^#\d+$/.test(card)) {
@@ -6043,6 +6115,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6043
6115
  if (card) q.set("requirement_id", card);
6044
6116
  if (args.since) q.set("since", args.since);
6045
6117
  if (args.limit !== undefined) q.set("limit", String(args.limit));
6118
+ else if (!card) q.set("limit", String(REQ_EVENTS_NETWORK_DEFAULT_LIMIT));
6046
6119
  if (args.cursor) q.set("cursor", args.cursor);
6047
6120
  return requirementsCall("GET", `/api/requirements/events${q.size ? `?${q}` : ""}`, args.network_id);
6048
6121
  },