board_update_project
Update a project's status, priority, name, description, or metadata. Pause on-hold projects, archive completed ones, or adjust portfolio priority during weekly reviews.
Instructions
Update a project's status (active/paused/completed/archived), priority, name, description, or metadata. Use this to pause projects that are on hold, archive completed projects so they don't clutter the active list, or re-rank portfolio priority during weekly reviews. Pass null to description/metadata to clear them.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| project_id | Yes | Project ID to update | |
| status | No | New status. 'paused' means intentionally on hold pending capacity or dependency — not stalled, not archived. Revisit at priority review. Valid transitions: active→paused/completed/archived, paused→active/completed/archived, completed→archived/active, archived→active. | |
| priority | No | Portfolio-level importance — drives weekly focus and default sort order in board_get_projects. DISTINCT from task.priority (which orders execution within a single project). Adjust during weekly portfolio reviews to promote/demote initiatives without touching the underlying tasks. | |
| name | No | Updated name | |
| description | No | Updated description. Pass null to clear; omit to leave unchanged. | |
| metadata | No | Metadata to shallow-merge with existing. Pass null to clear all metadata; omit to leave unchanged. |
Implementation Reference
- src/tools/projects.ts:219-357 (handler)Handler function for board_update_project tool. Updates a project's fields (status, priority, name, description, metadata) with validation: checks project existence, validates status transitions against allowed transitions map, conditionally updates each field, supports null to clear description/metadata, shallow-merges metadata with order-independent deep equality check, writes audit trail to activity_log, and bumps updated_at timestamp.
async ({ project_id, status, priority, name, description, metadata }) => { const docRef = db.collection("projects").doc(project_id); const snap = await docRef.get(); if (!snap.exists) { return { content: [ { type: "text" as const, text: JSON.stringify( { error: `Project ${project_id} not found` }, null, 2 ), }, ], }; } const existing = (snap.data() ?? {}) as Partial<ProjectData>; const updates: Record<string, unknown> = {}; const changes: string[] = []; if (status !== undefined && status !== existing.status) { const currentStatus = existing.status ?? "active"; const allowed = validTransitions[currentStatus] ?? []; if (!allowed.includes(status)) { return { content: [ { type: "text" as const, text: JSON.stringify( { error: `Invalid status transition: ${currentStatus} → ${status}. Allowed from ${currentStatus}: ${allowed.join(", ") || "(none)"}`, }, null, 2 ), }, ], }; } updates.status = status; changes.push(`status: ${currentStatus} → ${status}`); } if (priority !== undefined && priority !== existing.priority) { const prev = existing.priority ?? "medium"; updates.priority = priority; changes.push(`priority: ${prev} → ${priority}`); } if (name !== undefined && name !== existing.name) { updates.name = name; changes.push(`name updated`); } // description: null clears, string replaces, undefined leaves unchanged if (description !== undefined && description !== existing.description) { updates.description = description; // can be null or string changes.push(description === null ? `description cleared` : `description updated`); } // metadata: null clears, object shallow-merges, undefined leaves unchanged if (metadata !== undefined) { if (metadata === null) { // Only count as a change if there was actually metadata before if (existing.metadata && Object.keys(existing.metadata).length > 0) { updates.metadata = {}; changes.push(`metadata cleared`); } } else { const merged = { ...(existing.metadata ?? {}), ...metadata }; // Skip the change when nothing actually differs after merge. // Uses order-independent deepEqual to avoid false-positives from // key reordering (V8 auto-sorts numeric keys, spread order can shift). if (!deepEqual(merged, existing.metadata ?? {})) { updates.metadata = merged; changes.push(`metadata merged`); } } } if (changes.length === 0) { return { content: [ { type: "text" as const, text: JSON.stringify( { id: project_id, name: existing.name ?? null, status: existing.status ?? null, message: "No changes to apply", }, null, 2 ), }, ], }; } // Bump updated_at only when there are real changes const now = Timestamp.now(); updates.updated_at = now; await docRef.update(updates); // Mirror tasks.ts: write an activity_log entry for audit trail await db.collection("activity_log").add({ task_id: null, session_id: null, agent_name: "system", action: "updated", details: `Project ${project_id}: ${changes.join(", ")}`, metadata: { project_id }, created_at: now, }); return { content: [ { type: "text" as const, text: JSON.stringify( { id: project_id, name: (updates.name as string | undefined) ?? existing.name ?? null, status: (updates.status as string | undefined) ?? existing.status ?? null, changes, message: `Project updated: ${changes.join(", ")}`, }, null, 2 ), }, ], }; } ); - src/tools/projects.ts:197-218 (schema)Zod input schema for board_update_project. Accepts: project_id (string required), status (enum: active/paused/completed/archived, optional), priority (enum: critical/high/medium/low, optional), name (string, optional), description (string or null, optional), and metadata (record or null, optional for shallow-merge).
{ project_id: z.string().describe("Project ID to update"), status: z .enum(["active", "paused", "completed", "archived"]) .optional() .describe("New status. 'paused' means intentionally on hold pending capacity or dependency — not stalled, not archived. Revisit at priority review. Valid transitions: active→paused/completed/archived, paused→active/completed/archived, completed→archived/active, archived→active."), priority: z .enum(["critical", "high", "medium", "low"]) .optional() .describe("Portfolio-level importance — drives weekly focus and default sort order in board_get_projects. DISTINCT from task.priority (which orders execution within a single project). Adjust during weekly portfolio reviews to promote/demote initiatives without touching the underlying tasks."), name: z.string().optional().describe("Updated name"), description: z .string() .nullable() .optional() .describe("Updated description. Pass null to clear; omit to leave unchanged."), metadata: z .record(z.string(), z.unknown()) .nullable() .optional() .describe("Metadata to shallow-merge with existing. Pass null to clear all metadata; omit to leave unchanged."), }, - src/tools/projects.ts:194-357 (registration)Registration of board_update_project tool via server.tool() call within registerProjectTools(), which is exported and invoked in src/index.ts at line 28.
server.tool( "board_update_project", "Update a project's status (active/paused/completed/archived), priority, name, description, or metadata. Use this to pause projects that are on hold, archive completed projects so they don't clutter the active list, or re-rank portfolio priority during weekly reviews. Pass null to description/metadata to clear them.", { project_id: z.string().describe("Project ID to update"), status: z .enum(["active", "paused", "completed", "archived"]) .optional() .describe("New status. 'paused' means intentionally on hold pending capacity or dependency — not stalled, not archived. Revisit at priority review. Valid transitions: active→paused/completed/archived, paused→active/completed/archived, completed→archived/active, archived→active."), priority: z .enum(["critical", "high", "medium", "low"]) .optional() .describe("Portfolio-level importance — drives weekly focus and default sort order in board_get_projects. DISTINCT from task.priority (which orders execution within a single project). Adjust during weekly portfolio reviews to promote/demote initiatives without touching the underlying tasks."), name: z.string().optional().describe("Updated name"), description: z .string() .nullable() .optional() .describe("Updated description. Pass null to clear; omit to leave unchanged."), metadata: z .record(z.string(), z.unknown()) .nullable() .optional() .describe("Metadata to shallow-merge with existing. Pass null to clear all metadata; omit to leave unchanged."), }, async ({ project_id, status, priority, name, description, metadata }) => { const docRef = db.collection("projects").doc(project_id); const snap = await docRef.get(); if (!snap.exists) { return { content: [ { type: "text" as const, text: JSON.stringify( { error: `Project ${project_id} not found` }, null, 2 ), }, ], }; } const existing = (snap.data() ?? {}) as Partial<ProjectData>; const updates: Record<string, unknown> = {}; const changes: string[] = []; if (status !== undefined && status !== existing.status) { const currentStatus = existing.status ?? "active"; const allowed = validTransitions[currentStatus] ?? []; if (!allowed.includes(status)) { return { content: [ { type: "text" as const, text: JSON.stringify( { error: `Invalid status transition: ${currentStatus} → ${status}. Allowed from ${currentStatus}: ${allowed.join(", ") || "(none)"}`, }, null, 2 ), }, ], }; } updates.status = status; changes.push(`status: ${currentStatus} → ${status}`); } if (priority !== undefined && priority !== existing.priority) { const prev = existing.priority ?? "medium"; updates.priority = priority; changes.push(`priority: ${prev} → ${priority}`); } if (name !== undefined && name !== existing.name) { updates.name = name; changes.push(`name updated`); } // description: null clears, string replaces, undefined leaves unchanged if (description !== undefined && description !== existing.description) { updates.description = description; // can be null or string changes.push(description === null ? `description cleared` : `description updated`); } // metadata: null clears, object shallow-merges, undefined leaves unchanged if (metadata !== undefined) { if (metadata === null) { // Only count as a change if there was actually metadata before if (existing.metadata && Object.keys(existing.metadata).length > 0) { updates.metadata = {}; changes.push(`metadata cleared`); } } else { const merged = { ...(existing.metadata ?? {}), ...metadata }; // Skip the change when nothing actually differs after merge. // Uses order-independent deepEqual to avoid false-positives from // key reordering (V8 auto-sorts numeric keys, spread order can shift). if (!deepEqual(merged, existing.metadata ?? {})) { updates.metadata = merged; changes.push(`metadata merged`); } } } if (changes.length === 0) { return { content: [ { type: "text" as const, text: JSON.stringify( { id: project_id, name: existing.name ?? null, status: existing.status ?? null, message: "No changes to apply", }, null, 2 ), }, ], }; } // Bump updated_at only when there are real changes const now = Timestamp.now(); updates.updated_at = now; await docRef.update(updates); // Mirror tasks.ts: write an activity_log entry for audit trail await db.collection("activity_log").add({ task_id: null, session_id: null, agent_name: "system", action: "updated", details: `Project ${project_id}: ${changes.join(", ")}`, metadata: { project_id }, created_at: now, }); return { content: [ { type: "text" as const, text: JSON.stringify( { id: project_id, name: (updates.name as string | undefined) ?? existing.name ?? null, status: (updates.status as string | undefined) ?? existing.status ?? null, changes, message: `Project updated: ${changes.join(", ")}`, }, null, 2 ), }, ], }; } ); - src/tools/projects.ts:41-66 (helper)deepEqual helper used by the handler for order-independent deep equality comparison of metadata objects, preventing spurious 'changed' events when key order differs.
function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (a === null || b === null) return a === b; if (typeof a !== "object" || typeof b !== "object") return false; if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b)) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!deepEqual(a[i], b[i])) return false; } return true; } const aKeys = Object.keys(a as Record<string, unknown>); const bKeys = Object.keys(b as Record<string, unknown>); if (aKeys.length !== bKeys.length) return false; for (const k of aKeys) { if (!Object.prototype.hasOwnProperty.call(b, k)) return false; if (!deepEqual( (a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k] )) return false; } return true; }