@sleep2agi/commhub-server 0.9.0-preview.65 → 0.9.0-preview.67

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/src/tools.ts CHANGED
@@ -8,7 +8,8 @@ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetwork
8
8
  import { assertNodeActive } from "./lifecycle-guard.js";
9
9
  import { pendingInboxCount } from "./inbox-count.js";
10
10
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
11
- import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
11
+ import { canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId } from "./network-scope.js";
12
+ import { handleRequirementsRequest } from "./requirements.js";
12
13
  import {
13
14
  buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
14
15
  validateName as validateChildName,
@@ -64,7 +65,7 @@ import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
64
65
  import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
65
66
  import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
66
67
  import { parseHubTimestamp } from "./hub-timestamp";
67
- import { noteTerminalResultRead, sweepNodeRequestContent } from "./node-request-retention.js";
68
+ import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
68
69
 
69
70
  function ts(): string {
70
71
  return new Date().toTimeString().slice(0, 8);
@@ -730,8 +731,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
730
731
  skills_capable: z.literal(true).optional(),
731
732
  // Project folder view — same doorbell, ops files_list / file_read.
732
733
  files_capable: z.literal(true).optional(),
734
+ // Node run-log view — same doorbell, op logs_tail.
735
+ logs_capable: z.literal(true).optional(),
733
736
  },
734
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable }) => {
737
+ async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable }) => {
735
738
  const effectiveNetId = getNetworkId(netId);
736
739
  const sessionNetId = effectiveNetId ?? "default";
737
740
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -943,6 +946,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
943
946
  if (filesCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
944
947
  db.run("UPDATE sessions SET files_capable = 1 WHERE resume_id = ?1", [resume_id]);
945
948
  }
949
+ if (logsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
950
+ db.run("UPDATE sessions SET logs_capable = 1 WHERE resume_id = ?1", [resume_id]);
951
+ }
946
952
  if (host || proc) {
947
953
  db.run(
948
954
  `INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
@@ -3201,11 +3207,43 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3201
3207
  return segs.join("/");
3202
3208
  };
3203
3209
 
3210
+ // Node run-log view — logs_tail rides the same queue + doorbell. `content`
3211
+ // carries the filter parameters as JSON (lines / level / grep / since_ts) and
3212
+ // never a path: the node reads only its own log directory and redacts before
3213
+ // it answers (agent-node/src/runtime/node-logs.ts). Stricter than the project
3214
+ // folder on the caller: user login only, and only the node's owner or a
3215
+ // network owner/admin — a member who may chat with a node does not get to read
3216
+ // its process log. Log bytes are purged on the first terminal read (see
3217
+ // get_rules_file_result) and by the retention sweep after LOGS_CONTENT_TTL_MS.
3218
+ const isLogsOp = (op: string) => op === "logs_tail";
3219
+ const LOGS_MAX_LINES = 2000;
3220
+ const LOGS_GREP_MAX = 200;
3221
+ const logsParamsJson = (p: { lines?: number; level?: string; grep?: string; since_ts?: number }): string =>
3222
+ JSON.stringify({
3223
+ lines: Math.min(Math.max(Math.floor(p.lines ?? 500), 1), LOGS_MAX_LINES),
3224
+ ...(p.level ? { level: p.level } : {}),
3225
+ ...(p.grep ? { grep: p.grep.slice(0, LOGS_GREP_MAX) } : {}),
3226
+ ...(typeof p.since_ts === "number" && p.since_ts > 0 ? { since_ts: p.since_ts } : {}),
3227
+ });
3228
+ // Owner of the node row, or owner/admin of the network. Legacy open mode (no
3229
+ // user identity on the connection) keeps canWrite's allow-all semantics.
3230
+ const canReadNodeLogs = (nodeId: string, networkId: string): boolean => {
3231
+ if (!enforceUserId) return true;
3232
+ const role = getUserNetworkRole(enforceUserId, networkId);
3233
+ if (role === "owner" || role === "admin") return true;
3234
+ if (nodeId.startsWith("session:")) return false;
3235
+ const row = db.get<{ owner_user_id: string | null }>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", nodeId);
3236
+ return !!row?.owner_user_id && row.owner_user_id === enforceUserId;
3237
+ };
3238
+
3204
3239
  const enqueueRulesFileRequest = (
3205
- op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read",
3240
+ op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read" | "logs_tail",
3206
3241
  a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
3207
3242
  ) => {
3208
3243
  const effectiveNetId = getNetworkId(a.network_id);
3244
+ if (isLogsOp(op) && callerTokenIsNetwork) {
3245
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_read_logs", message: "a node token cannot read another node's run log; use a user login" }) }] };
3246
+ }
3209
3247
  if (isFilesOp(op)) {
3210
3248
  if (callerTokenIsNetwork) {
3211
3249
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_browse_files", message: "a node token cannot browse another node's project folder; use a user login" }) }] };
@@ -3217,7 +3255,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3217
3255
  }
3218
3256
  a = { ...a, content: rel };
3219
3257
  }
3220
- if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
3258
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isLogsOp(op) ? "read node logs" : isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
3221
3259
  if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
3222
3260
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_skill_name", reason: "name must match [A-Za-z0-9._-]{1,64} and not be . or .." }) }] };
3223
3261
  }
@@ -3247,7 +3285,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3247
3285
  node = byAlias;
3248
3286
  } else {
3249
3287
  const session = db.get<{ alias: string; network_id: string | null }>(
3250
- isFilesOp(op)
3288
+ isLogsOp(op)
3289
+ ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND logs_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3290
+ : isFilesOp(op)
3251
3291
  ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND files_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3252
3292
  : isSkillsOp(op)
3253
3293
  ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
@@ -3256,6 +3296,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3256
3296
  scopeNet,
3257
3297
  );
3258
3298
  if (!session) {
3299
+ if (isLogsOp(op)) {
3300
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its run log (its agent-node may be too old)" }) }] };
3301
+ }
3259
3302
  if (isFilesOp(op)) {
3260
3303
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "files_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its project folder (its channel server may be too old)" }) }] };
3261
3304
  }
@@ -3267,6 +3310,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3267
3310
  }
3268
3311
  }
3269
3312
  const nodeId = node.node_id;
3313
+ if (isLogsOp(op) && !canReadNodeLogs(nodeId, node.network_id || "default")) {
3314
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_permission_denied", message: "only the node's owner or a network owner/admin can read its run log" }) }] };
3315
+ }
3270
3316
  if (op === "write") {
3271
3317
  if (typeof a.content !== "string") {
3272
3318
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
@@ -3282,7 +3328,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3282
3328
  // Separate single-flight lanes: the client opens the rules file and the
3283
3329
  // skills list together; one must not block the other.
3284
3330
  const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
3285
- isFilesOp(op)
3331
+ isLogsOp(op)
3332
+ ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op = 'logs_tail' ORDER BY created_at DESC LIMIT 1"
3333
+ : isFilesOp(op)
3286
3334
  ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('files_list', 'file_read') ORDER BY created_at DESC LIMIT 1"
3287
3335
  : isSkillsOp(op)
3288
3336
  ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('skills_list', 'skill_read') ORDER BY created_at DESC LIMIT 1"
@@ -3306,7 +3354,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3306
3354
  const networkId = node.network_id || "default";
3307
3355
  db.run(
3308
3356
  `INSERT INTO node_rules_requests (request_id, node_id, network_id, op, content, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
3309
- [requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3357
+ [requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) || isLogsOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3310
3358
  );
3311
3359
  pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
3312
3360
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
@@ -3382,6 +3430,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3382
3430
  async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("file_read", { node_id, child_node_id, alias, content: relPath, network_id }),
3383
3431
  );
3384
3432
 
3433
+ server.tool(
3434
+ "tail_node_logs",
3435
+ "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.",
3436
+ {
3437
+ ...NODE_ID_ALIAS_FIELDS,
3438
+ 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."),
3439
+ lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
3440
+ level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
3441
+ grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
3442
+ since_ts: z.number().int().min(0).optional().describe("Only lines at or after this epoch-ms timestamp (follow mode; the client de-duplicates by line key)."),
3443
+ network_id: z.string().max(200).optional(),
3444
+ },
3445
+ async ({ node_id, child_node_id, alias, lines, level, grep, since_ts, network_id }) =>
3446
+ enqueueRulesFileRequest("logs_tail", { node_id, child_node_id, alias, content: logsParamsJson({ lines, level, grep, since_ts }), network_id }),
3447
+ );
3448
+
3385
3449
  server.tool(
3386
3450
  "get_rules_file_request",
3387
3451
  "Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
@@ -3419,7 +3483,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3419
3483
  request: {
3420
3484
  request_id: req.request_id,
3421
3485
  op: req.op,
3422
- ...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) ? { content: req.content ?? "" } : {}),
3486
+ ...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) || isLogsOp(req.op) ? { content: req.content ?? "" } : {}),
3423
3487
  },
3424
3488
  }),
3425
3489
  }],
@@ -3460,7 +3524,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3460
3524
  if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
3461
3525
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
3462
3526
  }
3463
- if (typeof content === "string" && !isFilesOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
3527
+ if (typeof content === "string" && !isFilesOp(req.op) && !isLogsOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
3464
3528
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", max: RULES_FILE_MAX_BYTES }) }] };
3465
3529
  }
3466
3530
  db.run(
@@ -3492,7 +3556,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3492
3556
  );
3493
3557
  // SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
3494
3558
  // Project folder results: only the (user) token that asked may read them.
3495
- const foreignFilesRow = !!row && isFilesOp(row.op) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
3559
+ // Run-log results: same rule — only the token that asked.
3560
+ const foreignFilesRow = !!row && (isFilesOp(row.op) || isLogsOp(row.op)) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
3496
3561
  if (!row || row.network_id !== (effectiveNetId || "default") || foreignFilesRow) {
3497
3562
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
3498
3563
  }
@@ -3511,6 +3576,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3511
3576
  // after the SEC-1 scope check above — a foreign caller can't stamp/purge.
3512
3577
  const terminal = status === "done" || status === "failed" || status === "timeout";
3513
3578
  const purged = terminal ? noteTerminalResultRead(requestId).purged : false;
3579
+ // Run-log bytes are read-once: purge them in the same call that hands them
3580
+ // out (no grace window — the app never follows someone else's logs request,
3581
+ // it re-asks; see node-request-retention.ts).
3582
+ if (terminal && isLogsOp(row.op) && !purged) purgeLogsResultNow(requestId);
3514
3583
  return {
3515
3584
  content: [{
3516
3585
  type: "text" as const,
@@ -5606,6 +5675,109 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5606
5675
  }
5607
5676
  },
5608
5677
  );
5678
+
5679
+ // ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
5680
+ // 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
5681
+ // 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。
5682
+ const requirementsCall = async (method: string, path: string, clientNetId?: string | null, body?: Record<string, unknown>) => {
5683
+ const scope = resolveRestNetworkScope(enforceNetworkId ? null : (clientNetId ?? null), mcpAuthCtx, false);
5684
+ if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "access_denied", message: scope.denied }) }] };
5685
+ const url = new URL(`http://mcp.internal${path}`);
5686
+ const req = new Request(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
5687
+ const res = await handleRequirementsRequest({
5688
+ req, url,
5689
+ auth: mcpAuthCtx ? { ...mcpAuthCtx, username: "", tokenId: callerTokenId ?? null, tokenName: callerTokenIsNetwork && callerAlias ? `node:${callerAlias}` : null } : null,
5690
+ isAdmin: false,
5691
+ isNodeToken: callerTokenIsNetwork,
5692
+ scope,
5693
+ });
5694
+ const data = res ? await res.json() : { ok: false, error: "not_found" };
5695
+ return { content: [{ type: "text" as const, text: JSON.stringify(res && !res.ok && data && typeof data === "object" ? { ...data, status: res.status } : data) }] };
5696
+ };
5697
+ const reqPerson = z.object({ kind: z.enum(["user", "node"]), id: z.string().min(1).max(200) });
5698
+ 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() });
5699
+ const reqFields = {
5700
+ name: z.string().min(1).max(80).optional(),
5701
+ priority: z.enum(["high", "normal", "low"]).optional(),
5702
+ column: z.enum(["pool", "doing", "done"]).optional(),
5703
+ due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
5704
+ description: z.string().max(20_000).optional().describe("markdown"),
5705
+ checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
5706
+ project_id: z.string().max(200).nullable().optional(),
5707
+ owner: reqPerson.nullable().optional().describe("负责人: must be {kind:'user'}"),
5708
+ agent_owner: reqPerson.nullable().optional().describe("负责 Agent: must be {kind:'node'}"),
5709
+ participants: z.array(reqPerson).max(100).optional(),
5710
+ external_url: z.string().max(500).nullable().optional(),
5711
+ parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
5712
+ };
5713
+ const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
5714
+ const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
5715
+
5716
+ server.tool(
5717
+ "requirements_list",
5718
+ "List requirement tasks in your network. Filters: status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived. Newest first, max 500. Each task carries children {total, done}.",
5719
+ {
5720
+ network_id: z.string().max(200).optional(),
5721
+ status: z.enum(["pool", "doing", "done"]).optional(),
5722
+ project_id: z.string().max(200).optional(),
5723
+ owner: z.string().max(210).optional(),
5724
+ agent_owner: z.string().max(210).optional(),
5725
+ updated_since: z.string().max(40).optional(),
5726
+ external_ref: z.string().max(200).optional(),
5727
+ include_archived: z.boolean().optional(),
5728
+ parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5729
+ top_level: z.boolean().optional(),
5730
+ },
5731
+ async (args) => {
5732
+ const q = new URLSearchParams();
5733
+ for (const k of ["status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5734
+ if (args.include_archived) q.set("include_archived", "1");
5735
+ if (args.top_level) q.set("top_level", "1");
5736
+ return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
5737
+ },
5738
+ );
5739
+
5740
+ server.tool(
5741
+ "requirements_get",
5742
+ "Get one requirement task by id (with description, checklist, owners, project, external_ref).",
5743
+ { id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
5744
+ async ({ id, network_id }) => requirementsCall("GET", `/api/requirements/${encodeURIComponent(id)}`, network_id),
5745
+ );
5746
+
5747
+ server.tool(
5748
+ "requirements_create",
5749
+ "Create a requirement task. name is required. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
5750
+ { network_id: z.string().max(200).optional(), ...reqFields, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
5751
+ async (args) => requirementsCall("POST", "/api/requirements", args.network_id, pick(args, REQ_WRITE_KEYS)),
5752
+ );
5753
+
5754
+ server.tool(
5755
+ "requirements_update",
5756
+ "Patch a requirement task; omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
5757
+ { id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
5758
+ async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
5759
+ );
5760
+
5761
+ server.tool(
5762
+ "requirements_checklist_toggle",
5763
+ "Set one checklist item done / not done (only that item is written, so concurrent edits of other items are kept).",
5764
+ { id: z.string().min(1).max(200), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
5765
+ async ({ id, item_id, done, network_id }) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(id)}/checklist/${encodeURIComponent(item_id)}`, network_id, { done }),
5766
+ );
5767
+
5768
+ server.tool(
5769
+ "requirements_upsert_by_external_ref",
5770
+ "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 status — are kept). Returns { requirement, created }.",
5771
+ { network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, archived: z.boolean().optional() },
5772
+ async (args) => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(args, REQ_WRITE_KEYS)),
5773
+ );
5774
+
5775
+ server.tool(
5776
+ "projects_list",
5777
+ "List requirement projects in your network (id, name, color, sort, archived).",
5778
+ { network_id: z.string().max(200).optional() },
5779
+ async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
5780
+ );
5609
5781
  }
5610
5782
 
5611
5783
  // ────────────────────────────────────────────────────────────────────