Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:43:26 +02:00
commit 5aa3e8d685
700 files changed
+132801

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
export const PROJECTBRAIN_ANALYSIS_WIDGET_URI = "ui://projectbrain/analysis-job-v1.html";
export const PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE = "text/html;profile=mcp-app";
export const PROJECTBRAIN_ANALYSIS_WIDGET_HTML = `<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,sans-serif}body{margin:0;padding:14px;background:transparent;color:CanvasText}.card{border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:14px;padding:14px;background:Canvas}.top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}h1{font-size:16px;margin:0 0 4px}.muted{opacity:.68;font-size:12px}.badge{font-size:11px;font-weight:700;padding:4px 8px;border-radius:999px;background:color-mix(in srgb,#2563eb 15%,Canvas)}.bar{height:7px;border-radius:999px;background:color-mix(in srgb,CanvasText 10%,transparent);overflow:hidden;margin:14px 0 8px}.fill{height:100%;background:#2563eb;width:0;transition:width .2s}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px 0}.stat{padding:8px;border-radius:10px;background:color-mix(in srgb,CanvasText 5%,transparent)}.stat strong{display:block;font-size:15px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}button{border:1px solid color-mix(in srgb,CanvasText 20%,transparent);background:Canvas;border-radius:9px;padding:7px 10px;color:inherit;cursor:pointer}button:disabled{opacity:.5;cursor:default}.results{display:grid;gap:7px;margin-top:12px}.result{font-size:12px;padding:8px;border-radius:9px;background:color-mix(in srgb,CanvasText 5%,transparent)}
</style>
</head>
<body>
<main class="card">
<div class="top"><div><h1>Repositoryanalyse</h1><div id="id" class="muted">Job laden…</div></div><span id="status" class="badge">—</span></div>
<div class="bar"><div id="fill" class="fill"></div></div>
<div id="progress" class="muted">Nog geen voortgang ontvangen.</div>
<div class="stats"><div class="stat"><strong id="done">0</strong><span class="muted">Afgerond</span></div><div class="stat"><strong id="findings">0</strong><span class="muted">Bevindingen</span></div><div class="stat"><strong id="failed">0</strong><span class="muted">Mislukt</span></div></div>
<div id="summary" class="muted">ProjectBrain bewaart resultaten buiten deze chat.</div>
<div class="actions"><button id="refresh">Status vernieuwen</button><button id="results">Eerste resultaten</button></div>
<section id="resultList" class="results"></section>
</main>
<script>
const pending=new Map();let nextId=1;let jobId;
const el=id=>document.getElementById(id);
const dataOf=value=>value?.structuredContent??value?.result?.structuredContent??value;
function render(value){const data=dataOf(value)||{};const job=data.job||data;if(!job?.id)return;jobId=job.id;const total=Number(job.progressTotal||0),done=Number(job.progressCurrent||0),summary=job.summaryJson||{};el('id').textContent='Job '+job.id;el('status').textContent=job.status||'—';el('fill').style.width=(total?Math.min(100,done/total*100):0)+'%';el('progress').textContent=done+' van '+total+' repositories verwerkt';el('done').textContent=done;el('findings').textContent=summary.findingCount??0;el('failed').textContent=summary.failed??0;el('summary').textContent=job.summaryText||'Analyse staat in de wachtrij.';}
function call(name,args){if(window.openai?.callTool)return window.openai.callTool(name,args);const id=nextId++;window.parent.postMessage({jsonrpc:'2.0',id,method:'tools/call',params:{name,arguments:args}},'*');return new Promise((resolve,reject)=>pending.set(id,{resolve,reject}));}
async function callCompatible(publicName,upstreamName,args){try{return await call(publicName,args);}catch{return call(upstreamName,args);}}
window.addEventListener('message',event=>{if(event.source!==window.parent)return;const message=event.data;if(!message||message.jsonrpc!=='2.0')return;if(message.id!==undefined&&pending.has(message.id)){const waiter=pending.get(message.id);pending.delete(message.id);message.error?waiter.reject(message.error):waiter.resolve(message.result);return;}if(message.method==='ui/notifications/tool-result')render(message.params);if(message.method==='openai:set_globals')render(message.params?.globals?.toolOutput??message.params?.toolOutput);},{passive:true});
el('refresh').onclick=async()=>{if(!jobId)return;el('refresh').disabled=true;try{const value=await callCompatible('projectbrain.analysis.status','get_analysis_job',{job_id:jobId});render(value);}finally{el('refresh').disabled=false;}};
el('results').onclick=async()=>{if(!jobId)return;el('results').disabled=true;try{const value=dataOf(await callCompatible('projectbrain.analysis.results','get_analysis_job_results',{job_id:jobId,page_size:5,character_budget:12000}))||{};const items=value.items||[];el('resultList').replaceChildren(...items.map(item=>{const node=document.createElement('div');node.className='result';node.textContent=(item.repository||'Repository')+' — '+(item.summary||item.status||'onbekend');return node;}));}finally{el('results').disabled=false;}};
let attempts=0;const hydrate=()=>{const output=window.openai?.toolOutput;if(output){render(output);return;}if(++attempts<50)setTimeout(hydrate,100);};hydrate();
</script>
</body>
</html>`;
+325
View File
@@ -0,0 +1,325 @@
import { z } from "zod";
import type { AppEnv } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, normalizeAutonomyState, reportAutonomyWork, resumeAutonomy, retryAutonomyBlockerIfDue } from "../repositories/autonomyRepository.js";
import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js";
import { compactAutonomyState, recordAutonomyExecutorHeartbeat, touchAutonomyExecutorHeartbeat } from "../services/autonomyRuntime.js";
import { syncGitSources } from "../services/gitSourceSync.js";
import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js";
import { reconcileGiteaAutonomyExecution } from "../services/giteaAutonomyExecution.js";
import { createProjectBrainMcpServer as createBaseProjectBrainMcpServer } from "./server.js";
const result = (data: Record<string, unknown>, text: string) => ({
content: [{ type: "text" as const, text }],
structuredContent: data,
});
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
const autonomyStatuses = ["off", "waiting_roadmap", "running", "blocked", "completed"] as const;
type AutoModeProjectRow = {
status: string;
name: string;
pending_work_items: number;
active_work_kind?: string;
last_run_at?: string;
};
const autoModeQueueRank = (row: AutoModeProjectRow) =>
row.status === "running" && !row.active_work_kind ? 0 : row.active_work_kind ? 1 : row.status === "blocked" ? 2 : 3;
const queueTimestamp = (value?: string) => {
const parsed = value ? Date.parse(value) : 0;
return Number.isFinite(parsed) ? parsed : 0;
};
export const compareAutoModeProjectRows = (left: AutoModeProjectRow, right: AutoModeProjectRow) => {
const rankDifference = autoModeQueueRank(left) - autoModeQueueRank(right);
if (rankDifference) return rankDifference;
if (autoModeQueueRank(left) === 0) {
const ageDifference = queueTimestamp(left.last_run_at) - queueTimestamp(right.last_run_at);
if (ageDifference) return ageDifference;
}
return right.pending_work_items - left.pending_work_items || left.name.localeCompare(right.name);
};
const reportWithConflictRetry = async (
projectId: string,
input: Parameters<typeof reportAutonomyWork>[1],
actor: string,
) => {
try {
return await reportAutonomyWork(projectId, input, actor);
} catch (error) {
if (!(error instanceof Error) || error.message !== "AUTO_MODE_CONFLICT") throw error;
return reportAutonomyWork(projectId, input, actor);
}
};
export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
const server = createBaseProjectBrainMcpServer(env, actor);
server.registerTool("list_auto_mode_projects", {
title: "List ProjectBrain Auto Mode projects",
description: "Discover projects that have durable Auto Mode state without scanning every project through separate MCP calls. Intended as the first call for scheduled or newly resumed ChatGPT runs so they can find enabled autonomous roadmaps efficiently.",
inputSchema: {
enabled_only: z.boolean().default(true),
statuses: z.array(z.enum(autonomyStatuses)).max(autonomyStatuses.length).optional(),
max_projects: z.number().int().min(1).max(50).default(20),
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ enabled_only, statuses, max_projects }) => {
const projects = await prisma.project.findMany({
where: { deletedAt: null },
orderBy: { updatedAt: "desc" },
select: { slug: true, readableId: true, name: true, workspaceState: { select: { notesJson: true } } },
});
const selectedStatuses = statuses?.length ? new Set(statuses) : undefined;
const rows = projects.flatMap((project) => {
const notes = asRecord(project.workspaceState?.notesJson);
const autonomy = asRecord(notes.autonomy);
if (!Object.keys(autonomy).length) return [];
const state = retryAutonomyBlockerIfDue(normalizeAutonomyState(autonomy));
const enabled = state.enabled;
const status = state.status;
if (enabled_only && !enabled) return [];
if (selectedStatuses && !selectedStatuses.has(status)) return [];
const roadmap = state.roadmap;
const phases = roadmap?.phases ?? [];
const currentPhaseIndex = roadmap?.currentPhaseIndex ?? 0;
const currentPhase = phases[currentPhaseIndex];
const activeLease = state.activeLease && Date.parse(state.activeLease.expiresAt) > Date.now() ? state.activeLease : undefined;
const phaseItems = phases.flatMap((phase) => phase.items);
const lastRun = state.runs.reduce<(typeof state.runs)[number] | undefined>((latest, candidate) =>
!latest || queueTimestamp(candidate.startedAt) > queueTimestamp(latest.startedAt) ? candidate : latest, undefined);
return [{
project_id: project.slug,
readable_id: project.readableId ?? undefined,
name: project.name,
enabled,
status,
roadmap_title: roadmap?.title,
current_phase_index: phases.length ? currentPhaseIndex : undefined,
phase_count: phases.length,
current_phase_title: currentPhase?.title,
pending_work_items: phaseItems.filter((item) => item.status === "pending" || item.status === "in_progress").length,
completed_work_items: phaseItems.filter((item) => item.status === "completed").length,
max_work_items_per_run: state.maxWorkItemsPerRun,
max_run_minutes: state.maxRunMinutes,
last_run_at: lastRun?.startedAt,
active_work_kind: activeLease?.kind,
active_run_id: activeLease?.runId,
lease_expires_at: activeLease?.expiresAt,
blocker_reason: state.blocker?.reason,
blocker_kind: state.blocker?.kind,
retry_at: state.blocker?.retryAt,
}];
}).sort(compareAutoModeProjectRows).slice(0, max_projects);
return result({ projects: rows }, `${rows.length} Auto Mode project(s) matched. Claimable projects are ordered fairly by oldest last run (never-run projects first), so select the first row and keep processing claim_auto_work → execute → report_auto_work until that project's item or time budget is reached, work becomes busy or terminal, or user action is required.`);
});
server.registerTool("heartbeat_auto_executor", {
title: "Report ProjectBrain autonomous executor heartbeat",
description: "Report one bounded scheduled executor wake-up so ProjectBrain can distinguish queued autonomous work from a connected executor. Ordinary ChatGPT web tasks may run different projects concurrently; active_project_id selects the isolated ownership slot. Call at the start and end of every scheduled run. Normal claim/report calls refresh the heartbeat automatically; add an explicit heartbeat only immediately before and after an external wait that may exceed five minutes. A different run cannot replace the current owner of the same project inside the server-enforced run window.",
inputSchema: {
instance_id: z.string().trim().min(3).max(120),
run_id: z.string().trim().min(1).max(160).optional(),
status: z.enum(["idle", "working", "degraded"]),
active_project_id: z.string().trim().min(1).max(240).optional(),
detail: z.string().trim().min(1).max(1_000).optional(),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ instance_id, run_id, status, active_project_id, detail }) => {
const recorded = await recordAutonomyExecutorHeartbeat({
instanceId: instance_id,
...(run_id ? { runId: run_id } : {}),
status,
...(active_project_id ? { activeProjectId: active_project_id } : {}),
...(detail ? { detail } : {}),
});
const heartbeat = recorded.heartbeat;
return result({
accepted: recorded.accepted,
...(recorded.accepted ? {} : { rejection_reason: recorded.reason }),
executor: {
instance_id: heartbeat.instanceId,
run_id: heartbeat.runId ?? undefined,
status: heartbeat.status,
active_project_id: heartbeat.activeProjectId ?? undefined,
detail: heartbeat.detail ?? undefined,
last_seen_at: heartbeat.lastSeenAt.toISOString(),
},
}, recorded.accepted
? `Autonomous executor '${heartbeat.instanceId}' is ${heartbeat.status}; heartbeat recorded.`
: `Heartbeat rejected (${recorded.reason}); active run '${heartbeat.runId ?? "unknown"}' retains executor ownership. Do not claim work for the rejected run.`);
});
server.registerTool("get_auto_mode_state", {
title: "Get ProjectBrain Auto Mode state",
description: "Read the durable Auto Mode controller for one project and reconcile any newly observed canonical Gitea branch/PR/validation/merge evidence first. The default summary omits the large roadmap Markdown and returns bounded current-phase items and recent events. Request full only for targeted diagnostics that genuinely require the complete durable state.",
inputSchema: {
project_id: z.string().min(1),
view: z.enum(["summary", "full"]).default("summary"),
item_limit: z.number().int().min(1).max(25).default(25),
event_limit: z.number().int().min(0).max(20).default(5),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, view, item_limit, event_limit }) => {
let state = await getAutonomyState(project_id);
try {
state = (await reconcileGiteaAutonomyExecution(env, project_id, "system:mcp-auto-read")).state;
} catch {
// Durable ProjectBrain state remains readable during a transient Gitea read failure.
}
const projected = view === "full" ? state : compactAutonomyState(state, { itemLimit: item_limit, eventLimit: event_limit });
return result({ state: projected as unknown as Record<string, unknown>, view }, `Auto Mode is ${state.enabled ? "enabled" : "disabled"}; state=${state.status}${state.roadmap ? `, roadmap='${state.roadmap.title}', phase ${state.roadmap.currentPhaseIndex + 1}/${state.roadmap.phases.length}` : ""}; view=${view}.`);
});
server.registerTool("configure_auto_mode", {
title: "Configure ProjectBrain Auto Mode",
description: "Enable or disable autonomous roadmap execution for one project and configure the bounded brainstorm/failure/lease policy. When a linked repository exposes ROADMAP.md or a supported structured backlog, enabling Auto Mode synchronizes and bootstraps that repository plan automatically; explicit manually imported plans are preserved.",
inputSchema: {
project_id: z.string().min(1),
profile: z.enum(["conservative", "normal", "autonomous", "custom"]).optional(),
enabled: z.boolean().optional(),
auto_brainstorm: z.boolean().optional(),
max_brainstorm_iterations: z.number().int().min(1).max(10).optional(),
max_consecutive_failures: z.number().int().min(1).max(10).optional(),
lease_minutes: z.number().int().min(5).max(120).optional(),
max_work_items_per_run: z.number().int().min(1).max(20).optional(),
max_run_minutes: z.number().int().min(5).max(55).optional(),
max_recovery_minutes: z.number().int().min(15).max(1_440).optional(),
pause_after_current: z.boolean().optional(),
scheduling_policy: z.enum(["balanced", "downstream-impact"]).optional(),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, profile, enabled, auto_brainstorm, max_brainstorm_iterations, max_consecutive_failures, lease_minutes, max_work_items_per_run, max_run_minutes, max_recovery_minutes, pause_after_current, scheduling_policy }) => {
let state = await configureAutonomy(project_id, {
...(profile ? { profile } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(auto_brainstorm !== undefined ? { autoBrainstorm: auto_brainstorm } : {}),
...(max_brainstorm_iterations !== undefined ? { maxBrainstormIterations: max_brainstorm_iterations } : {}),
...(max_consecutive_failures !== undefined ? { maxConsecutiveFailures: max_consecutive_failures } : {}),
...(lease_minutes !== undefined ? { leaseMinutes: lease_minutes } : {}),
...(max_work_items_per_run !== undefined ? { maxWorkItemsPerRun: max_work_items_per_run } : {}),
...(max_run_minutes !== undefined ? { maxRunMinutes: max_run_minutes } : {}),
...(max_recovery_minutes !== undefined ? { maxRecoveryMinutes: max_recovery_minutes } : {}),
...(pause_after_current !== undefined ? { pauseAfterCurrent: pause_after_current } : {}),
...(scheduling_policy ? { schedulingPolicy: scheduling_policy } : {}),
}, actor);
let repositorySyncWarning: string | undefined;
if (state.enabled) {
try {
const sourceSync = await syncGitSources(env, { projectId: project_id });
if (sourceSync.errors.length) repositorySyncWarning = sourceSync.errors.map((entry) => `${entry.repository}: ${entry.code}`).join("; ");
} catch (error) {
repositorySyncWarning = error instanceof Error ? error.message : "REPOSITORY_SOURCE_SYNC_FAILED";
}
const repositoryPlan = await reconcileRepositoryAutonomyRoadmap(project_id, actor);
state = repositoryPlan.state;
}
return result({
state: state as unknown as Record<string, unknown>,
...(repositorySyncWarning ? { repository_sync_warning: repositorySyncWarning } : {}),
}, `Auto Mode configuration saved; enabled=${state.enabled}, state=${state.status}.${repositorySyncWarning ? ` Repository source refresh warning: ${repositorySyncWarning}` : ""}`);
});
server.registerTool("import_auto_roadmap", {
title: "Import ProjectBrain autonomous roadmap",
description: "Import a Markdown roadmap into ProjectBrain Auto Mode. H2+ headings become ordered phases and checkbox, bullet or numbered lines become work items. Import replaces the active autonomous roadmap but preserves Auto Mode policy settings.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(2).max(200).optional(), markdown: z.string().trim().min(3).max(100_000) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, markdown }) => {
const state = await importAutonomyRoadmap(project_id, { title, markdown }, actor);
const roadmap = state.roadmap!;
return result({ state: state as unknown as Record<string, unknown> }, `Roadmap '${roadmap.title}' imported with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`);
});
server.registerTool("resume_auto_mode", {
title: "Resume blocked ProjectBrain Auto Mode",
description: "Clear the current Auto Mode blocker and make its blocked roadmap phase/item claimable again. The roadmap, completed phases and completed work are preserved; only blocker/retry state for the blocked work is reset.",
inputSchema: { project_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id }) => {
const state = await resumeAutonomy(project_id, actor);
return result({ state: state as unknown as Record<string, unknown> }, `Auto Mode resume applied; enabled=${state.enabled}, state=${state.status}.`);
});
server.registerTool("claim_auto_work", {
title: "Claim next ProjectBrain autonomous work",
description: "Backward-compatible controller claim for one Auto work item. Implementation work returns a canonical Gitea execution target whose deterministic branch is the atomic execution claim. Heartbeat and lease ownership only coordinate legacy controller runs; repository correctness is reconciled from Gitea branch, pull-request, exact-head validation and merge evidence.",
inputSchema: {
project_id: z.string().min(1),
run_id: z.string().trim().min(1).max(160),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, run_id }) => {
const ownsExecutor = await touchAutonomyExecutorHeartbeat(run_id, project_id);
if (!ownsExecutor) {
const state = await getAutonomyState(project_id);
return result({ state: state as unknown as Record<string, unknown>, work: undefined, busy: true, heartbeat_required: true }, "This run does not own an accepted working executor heartbeat. Do not claim or execute work; another run may be active, or heartbeat_auto_executor must be called first.");
}
let repositorySyncWarning: string | undefined;
const beforeClaimState = await getAutonomyState(project_id);
if (!beforeClaimState.runs.some((run) => run.id === run_id)) {
try {
const sourceSync = await syncGitSources(env, { projectId: project_id });
if (sourceSync.errors.length) repositorySyncWarning = sourceSync.errors.map((entry) => `${entry.repository}: ${entry.code}`).join("; ");
} catch (error) {
repositorySyncWarning = error instanceof Error ? error.message : "REPOSITORY_SOURCE_SYNC_FAILED";
}
}
try {
try {
await reconcileGiteaAutonomyExecution(env, project_id, "system:mcp-auto-claim");
} catch {
// Keep the compatibility claim path available during transient Gitea read failures.
}
const claim = await claimAutonomyWork(project_id, actor, run_id);
const work = claim.work;
return result({ ...claim, state: compactAutonomyState(claim.state, { itemLimit: 5, eventLimit: 2 }), ...(repositorySyncWarning ? { repository_sync_warning: repositorySyncWarning } : {}) } as unknown as Record<string, unknown>, (work ? `${claim.resumed ? "Resumed" : "Claimed"} ${work.kind}: ${work.itemTitle ?? work.phaseTitle}. Execute the returned prompt, report with token ${work.token}, and continue within the run budget.` : claim.budgetReached ? `This run reached its server-enforced ${claim.budgetReason === "item_limit" ? "item" : "time"} budget; stop cleanly and leave the recurring task enabled.` : claim.busy ? `Autonomous work is already leased by another executor run; do not duplicate it. Controller state=${claim.state.status}.` : `No autonomous work is currently claimable; controller state=${claim.state.status}.`) + (repositorySyncWarning ? ` Repository source refresh warning: ${repositorySyncWarning}` : ""));
} catch (error) {
if (!(error instanceof Error) || error.message !== "AUTO_MODE_CONFLICT") throw error;
const claim = await claimAutonomyWork(project_id, actor, run_id);
const work = claim.work;
return result({ ...claim, state: compactAutonomyState(claim.state, { itemLimit: 5, eventLimit: 2 }) } as unknown as Record<string, unknown>, work ? `Concurrent claim resolved by resuming this run's durable ${work.kind} lease for ${work.itemTitle ?? work.phaseTitle}. Report with token ${work.token} and continue within the run budget.` : `Concurrent claim resolved without duplicating another run; controller state=${claim.state.status}.`);
}
});
server.registerTool("report_auto_work", {
title: "Report ProjectBrain autonomous work",
description: "Backward-compatible bookkeeping for a claimed lease. Canonical implementation truth comes from the deterministic Gitea branch, PR, exact-head Actions validation and merge; ProjectBrain reconciles that evidence even when this callback is never sent. delivery_id remains supported for legacy feature deliveries. Use discovered_tasks for repair prerequisites and reserve human blockers for actual owner decisions.",
inputSchema: {
project_id: z.string().min(1), token: z.string().uuid(), outcome: z.enum(["success", "failed", "blocked"]), summary: z.string().trim().min(3).max(12_000),
blocker_type: z.enum(["human", "retryable"]).optional(),
retry_after_minutes: z.number().int().min(5).max(1_440).optional(),
delivery_id: z.string().trim().min(1).max(240).optional(),
discovered_tasks: z.array(z.string().trim().min(2).max(1_000)).max(100).default([]),
brainstorm: z.object({
context_summary: z.string().trim().min(3).max(8_000).optional(),
ideas: z.array(z.object({ id: z.string().min(1).max(160), title: z.string().min(1).max(300), description: z.string().min(1).max(4_000), score: z.number().min(0).max(100).optional() })).max(50).default([]),
outcomes: z.array(z.string().min(1).max(2_000)).max(50).default([]),
selected_idea_ids: z.array(z.string().min(1).max(160)).max(50).default([]),
next_actions: z.array(z.string().trim().min(2).max(1_000)).max(100).default([]),
}).optional(),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, token, outcome, summary, blocker_type, retry_after_minutes, delivery_id, discovered_tasks, brainstorm }) => {
const reportInput = {
token, outcome, summary, ...(blocker_type ? { blockerType: blocker_type } : {}), ...(retry_after_minutes ? { retryAfterMinutes: retry_after_minutes } : {}), ...(delivery_id ? { deliveryId: delivery_id } : {}), discoveredTasks: discovered_tasks,
...(brainstorm ? { brainstorm: { ...(brainstorm.context_summary ? { contextSummary: brainstorm.context_summary } : {}), ideas: brainstorm.ideas, outcomes: brainstorm.outcomes, selectedIdeaIds: brainstorm.selected_idea_ids, nextActions: brainstorm.next_actions } } : {}),
} satisfies Parameters<typeof reportAutonomyWork>[1];
try {
await reconcileGiteaAutonomyExecution(env, project_id, "system:mcp-auto-report");
} catch {
// A compatibility report can still be recorded if Gitea is temporarily unavailable.
}
const report = await reportWithConflictRetry(project_id, reportInput, actor);
if (report.runId) await touchAutonomyExecutorHeartbeat(report.runId, project_id);
return result({ ...report, state: compactAutonomyState(report.state, { itemLimit: 5, eventLimit: 2 }) } as unknown as Record<string, unknown>, `${report.replayed ? "Replayed" : "Recorded"} autonomous work as ${outcome}; ${report.addedTasks.length} new task(s) added. Controller state=${report.state.status}.`);
});
return server;
};
+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod";
import { assertFileSize, assertWritablePath } from "../integrations/gitea/policy.js";
export const featureChangesetOperationSchema = z.discriminatedUnion("operation", [
z.object({ operation: z.literal("create"), path: z.string().min(1), content: z.string(), expected_sha: z.undefined().optional() }),
z.object({ operation: z.literal("update"), path: z.string().min(1), content: z.string(), expected_sha: z.string().min(1).max(200) }),
z.object({ operation: z.literal("delete"), path: z.string().min(1), expected_sha: z.string().min(1).max(200) }),
]);
export type FeatureChangesetOperation = z.infer<typeof featureChangesetOperationSchema>;
export const assertFeatureChangesetBudget = (operations: FeatureChangesetOperation[]) => {
const paths = operations.map((operation) => assertWritablePath(operation.path));
if (new Set(paths).size !== paths.length) throw new Error("FEATURE_CHANGESET_DUPLICATE_PATH");
const bytes = operations.reduce((total, operation) => total + ("content" in operation ? Buffer.byteLength(operation.content, "utf8") : 0), 0);
if (bytes > 1024 * 1024) throw new Error("FEATURE_CHANGESET_TOO_LARGE");
for (const operation of operations) if ("content" in operation) assertFileSize(operation.content);
return paths;
};
export const mapBounded = async <T, R>(items: T[], limit: number, operation: (item: T, index: number) => Promise<R>) => {
const output: R[] = [];
for (let index = 0; index < items.length; index += limit) {
output.push(...await Promise.all(items.slice(index, index + limit).map((item, offset) => operation(item, index + offset))));
}
return output;
};
+239
View File
@@ -0,0 +1,239 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { AppEnv } from "../config/env.js";
import { assertReadablePath } from "../integrations/gitea/policy.js";
import {
listPortfolioRepositories,
parsePortfolioFetchId,
portfolioFetchId,
type PortfolioRepository,
} from "../integrations/gitea/portfolio.js";
import { getGiteaClient, recordMcpAudit } from "../integrations/gitea/service.js";
import type { GiteaContent } from "../integrations/gitea/types.js";
type ToolResult = {
content: Array<{ type: "text"; text: string }>;
structuredContent?: Record<string, unknown>;
isError?: boolean;
};
const asResult = (data: unknown, message = "Operation completed"): ToolResult => ({
content: [{ type: "text", text: `${message}\n${JSON.stringify(data, null, 2)}` }],
structuredContent:
data && typeof data === "object" && !Array.isArray(data)
? (data as Record<string, unknown>)
: { data },
});
const asKnowledgeResult = (data: Record<string, unknown>): ToolResult => ({
content: [{ type: "text", text: JSON.stringify(data) }],
});
const decodeFile = (content: GiteaContent) => {
if (
content.type !== "file" ||
content.encoding !== "base64" ||
typeof content.content !== "string"
) {
throw new Error("GITEA_CONTENT_NOT_FILE");
}
return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8");
};
const mapBounded = async <T, R>(
items: T[],
limit: number,
operation: (item: T) => Promise<R>,
): Promise<R[]> => {
const output: R[] = [];
for (let index = 0; index < items.length; index += limit) {
output.push(...(await Promise.all(items.slice(index, index + limit).map(operation))));
}
return output;
};
const repositoryKey = (repository: Pick<PortfolioRepository, "owner" | "repository">) =>
`${repository.owner}/${repository.repository}`.toLowerCase();
const errorCode = (error: unknown) =>
error instanceof Error ? error.message : "MCP_PORTFOLIO_OPERATION_FAILED";
const auditedRead = async (
actor: string,
toolName: string,
operation: () => Promise<ToolResult>,
context: { repository?: string; branch?: string } = {},
): Promise<ToolResult> => {
const started = Date.now();
try {
const result = await operation();
await recordMcpAudit({
actor,
toolName,
repository: context.repository,
branch: context.branch,
outcome: "succeeded",
durationMs: Date.now() - started,
metadata: { projection: "gitea-portfolio", readOnly: true },
}).catch(() => undefined);
return result;
} catch (error) {
const code = errorCode(error);
await recordMcpAudit({
actor,
toolName,
repository: context.repository,
branch: context.branch,
outcome: "failed",
errorCode: code,
durationMs: Date.now() - started,
metadata: { projection: "gitea-portfolio", readOnly: true },
}).catch(() => undefined);
return {
content: [{ type: "text", text: `Operation failed: ${code}` }],
structuredContent: { error: code },
isError: true,
};
}
};
/**
* Register cross-repository search and fetch tools without changing the
* existing portfolio catalog or the GiteaRepositoryLink ownership/write
* boundary used by ProjectBrain delivery.
*/
export const registerPortfolioMcpTools = (
server: McpServer,
env: AppEnv,
actor: string,
) => {
const client = getGiteaClient(env);
server.registerTool(
"search_portfolio_repositories",
{
title: "Search Gitea portfolio repositories",
description:
"Search code across every repository visible to ProjectBrain's scoped Gitea identity. Returned gitea-repository:// ids can be read with fetch_portfolio_file without creating ProjectBrain ownership links.",
inputSchema: {
query: z.string().trim().min(2).max(200).describe("Plain-text code search query"),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ query }) =>
auditedRead(actor, "search_portfolio_repositories", async () => {
const repositories = await listPortfolioRepositories(env);
const searched = repositories.filter((repository) => !repository.archived);
const batches = await mapBounded(searched, 6, async (repository) => {
try {
const response = await client.searchCode(
repository.owner,
repository.repository,
query,
repository.defaultBranch,
);
const rows = Array.isArray(response) ? response : response.data ?? [];
return {
results: rows.map((row) => ({
id: portfolioFetchId(repository, repository.defaultBranch, row.path),
title: `${repository.fullName}: ${row.path}`,
url: row.html_url,
metadata: {
repository: repository.fullName,
ref: repository.defaultBranch,
path: row.path,
linked: repository.linked,
projectId: repository.link?.projectSlug,
},
})),
};
} catch {
return { results: [], failedRepository: repository.fullName };
}
});
const allResults = batches.flatMap((batch) => batch.results);
const failedRepositories = batches
.map((batch) => batch.failedRepository)
.filter((value): value is string => typeof value === "string");
return asKnowledgeResult({
results: allResults.slice(0, 50),
metadata: {
repositoryCount: repositories.length,
searchedRepositoryCount: searched.length,
failedRepositories,
truncated: allResults.length > 50,
},
});
}),
);
server.registerTool(
"fetch_portfolio_file",
{
title: "Fetch Gitea portfolio file",
description:
"Read one text file by the canonical gitea-repository:// id returned by search_portfolio_repositories. The repository must still be visible to ProjectBrain's scoped Gitea identity and sensitive paths remain blocked.",
inputSchema: {
id: z
.string()
.startsWith("gitea-repository://")
.max(2_000)
.describe("Canonical gitea-repository://owner/repository/ref/path identifier"),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ id }) => {
let parsed: ReturnType<typeof parsePortfolioFetchId>;
try {
parsed = parsePortfolioFetchId(id);
} catch (error) {
return asResult({ error: errorCode(error) }, "Invalid portfolio fetch id");
}
return auditedRead(
actor,
"fetch_portfolio_file",
async () => {
const repositories = await listPortfolioRepositories(env);
const visible = new Map(repositories.map((repository) => [repositoryKey(repository), repository]));
const repository = visible.get(`${parsed.owner}/${parsed.repository}`.toLowerCase());
if (!repository) throw new Error("GITEA_REPOSITORY_NOT_VISIBLE");
const path = assertReadablePath(parsed.path);
const file = await client.getContents(
repository.owner,
repository.repository,
path,
parsed.ref,
);
if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
return asKnowledgeResult({
id,
title: `${repository.fullName}: ${path}`,
text: decodeFile(file),
url: file.html_url,
metadata: {
repository: repository.fullName,
path,
ref: parsed.ref,
sha: file.sha,
size: file.size,
linked: repository.linked,
projectId: repository.link?.projectSlug,
},
});
},
{ repository: `${parsed.owner}/${parsed.repository}`, branch: parsed.ref },
);
},
);
};
+60
View File
@@ -0,0 +1,60 @@
import { GiteaApiError } from "../integrations/gitea/client.js";
import type { GiteaContent } from "../integrations/gitea/types.js";
export type ToolResult = { content: Array<{ type: "text"; text: string }>; structuredContent?: Record<string, unknown>; isError?: boolean };
export const MAX_MCP_READ_BYTES = 512 * 1024;
export const asResult = (data: unknown, message = "Operation completed"): ToolResult => ({ content: [{ type: "text", text: message }], structuredContent: data && typeof data === "object" && !Array.isArray(data) ? data as Record<string, unknown> : { data } });
export const asStandardKnowledgeResult = (data: Record<string, unknown>): ToolResult => ({ content: [{ type: "text", text: JSON.stringify(data) }] });
export const asCompactResult = (data: Record<string, unknown>, message: string): ToolResult => ({ content: [{ type: "text", text: message }], structuredContent: data });
export const errorCode = (error: unknown) => error instanceof GiteaApiError ? error.code : error instanceof Error ? error.message : "MCP_OPERATION_FAILED";
export const operationRecovery = (code: string) => {
if (/CHECKS_PENDING/.test(code)) return { action: "wait_and_resume", retryable: true, detail: "Required checks are still queued/running. Keep the same delivery, branch and PR; inspect status after the indicated wait and retry the merge with the current head." };
if (/CI_|CHECKS_FAILED|VALIDATION_FAILED/.test(code)) return { action: "repair_and_revalidate", retryable: true, detail: "Read exact-head validation runs and the selected failed-job evidence. Fix the cause on the existing delivery branch. For canceled/transient runs, rerun the affected workflow. Sync successful evidence, then retry this PR's merge; never create a replacement branch or weaken checks." };
if (/HEAD_CONFLICT|FILE_CONFLICT|AUTO_MODE_CONFLICT/.test(code)) return { action: "reload_current_state", retryable: true, detail: "Reload the existing delivery, branch head and affected file SHAs. Reconcile actual progress before retrying; do not replay stale writes." };
if (/MERGE_UNCONFIRMED|PULL_REQUEST_NOT_READY/.test(code)) return { action: "reconcile_existing_pr", retryable: true, detail: "Read the existing PR and retry merge_feature_delivery with its expected head. An already merged PR is reconciled without another merge." };
return { action: "inspect_existing_delivery", retryable: false, detail: "Read the existing delivery and repository state before acting. Do not create another branch to work around a tool error." };
};
export const pageCollection = <T>(items: T[], cursor: number, pageSize: number) => ({ items: items.slice(cursor, cursor + pageSize), total: items.length, nextCursor: cursor + pageSize < items.length ? String(cursor + pageSize) : undefined });
export const compactRecord = (value: unknown): Record<string, unknown> => {
if (!value || typeof value !== "object" || Array.isArray(value)) return { value };
const item = value as Record<string, unknown>;
const spec = item.spec && typeof item.spec === "object" && !Array.isArray(item.spec) ? item.spec as Record<string, unknown> : undefined;
const summaryCandidate = [item.summary, item.contextSummary, item.rule, item.intent, item.content]
.find((candidate): candidate is string => typeof candidate === "string" && candidate.length > 0);
return Object.fromEntries(Object.entries({
id: item.id,
projectId: item.projectId,
title: item.title,
status: item.status ?? spec?.status,
approvalState: item.approvalState,
kind: item.kind,
category: item.category,
scope: item.scope,
severity: item.severity,
confidence: item.confidence,
priority: item.priority,
branch: spec?.branch,
pullRequest: spec?.pullRequest,
summary: summaryCandidate ? `${summaryCandidate.slice(0, 600)}${summaryCandidate.length > 600 ? "…" : ""}` : undefined,
updatedAt: item.updatedAt,
}).filter(([, field]) => field !== undefined));
};
export const compactDelivery = (value: unknown) => compactRecord(value);
export const decodeFile = (content: GiteaContent) => {
if (content.type !== "file" || content.encoding !== "base64" || typeof content.content !== "string") throw new Error("GITEA_CONTENT_NOT_FILE");
if (content.size > MAX_MCP_READ_BYTES) throw new Error("MCP_FILE_READ_TOO_LARGE");
return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8");
};
export const parseFetchId = (id: string) => {
const match = id.match(/^gitea:\/\/([^/]+)\/([^/]+)\/(.+)$/);
if (!match) throw new Error("MCP_FETCH_ID_INVALID");
return { projectId: decodeURIComponent(match[1]), ref: decodeURIComponent(match[2]), path: decodeURIComponent(match[3]) };
};
+23
View File
@@ -0,0 +1,23 @@
import { getLinkedRepository, listProjectRepositories, type LinkedRepository } from "../integrations/gitea/service.js";
import { getFindingInvestigation, getRepositoryFinding } from "../repositories/findingRepository.js";
export class PartialExternalMutationError extends Error {}
export const requireLink = async (projectId: string, write = false, repositoryId?: string): Promise<LinkedRepository> => {
const link = repositoryId ? (await listProjectRepositories(projectId)).find((item) => item.id === repositoryId) : await getLinkedRepository(projectId);
if (!link) throw new Error("GITEA_LINK_NOT_FOUND");
if (write && link.accessMode !== "read_write") throw new Error("GITEA_REPOSITORY_READ_ONLY");
return link;
};
export const assertFindingProject = async (projectId: string, findingId: string) => {
const [link, finding] = await Promise.all([requireLink(projectId), getRepositoryFinding(findingId)]);
if (finding.projectId !== link.projectId) throw new Error("FINDING_PROJECT_SCOPE_MISMATCH");
return finding;
};
export const assertInvestigationProject = async (projectId: string, investigationId: string) => {
const [link, investigation] = await Promise.all([requireLink(projectId), getFindingInvestigation(investigationId)]);
if (investigation.projectId !== link.projectId) throw new Error("FINDING_PROJECT_SCOPE_MISMATCH");
return investigation;
};
+799
View File
@@ -0,0 +1,799 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { AppEnv } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { GiteaApiError } from "../integrations/gitea/client.js";
import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, exactHeadActionRuns, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js";
import { finishExternalOperation, getGiteaClient, listLinkedRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, type LinkedRepository } from "../integrations/gitea/service.js";
import type { GiteaContent } from "../integrations/gitea/types.js";
import { assertFeatureDeliveryActionEvidence, canSyncFeatureDeliveryValidation, getFeatureDelivery, listFeatureDeliveries, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery, updateFeatureDelivery } from "../repositories/featureDeliveryRepository.js";
import { createBrainstormForProject, listBrainstormsForProject } from "../repositories/brainstormRepository.js";
import { createContextPackForProject, listContextPacksForProject } from "../repositories/contextPacksRepository.js";
import { createDocumentationItem, createKnowledgeItem, createMemoryRuleItem, listDocumentationForProject, listKnowledgeForProject, listMemoryRulesForProject } from "../repositories/p8KnowledgeMemoryRepository.js";
import { listProjects } from "../repositories/projectsRepository.js";
import { listReviewItemsFromDatabase } from "../repositories/reviewQueueRepository.js";
import { importCodexOutputInDatabase } from "../repositories/p7RuntimeRepository.js";
import { PROJECTBRAIN_WORKSPACE_HTML, PROJECTBRAIN_WORKSPACE_MIME_TYPE, PROJECTBRAIN_WORKSPACE_URI } from "./widget.js";
import { resolveProjectContext } from "../services/contextResolver.js";
import { cancelAnalysisJob, createPortfolioAnalysisJob, getAnalysisJob, getAnalysisJobResults, listAnalysisJobs } from "../repositories/analysisJobRepository.js";
import { PROJECTBRAIN_ANALYSIS_WIDGET_HTML, PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE, PROJECTBRAIN_ANALYSIS_WIDGET_URI } from "./analysisWidget.js";
import { createDeliveryFromFindingInvestigation, getFindingInvestigation, getRepositoryFinding, listRepositoryFindings, queueFindingVerificationForDelivery, saveFindingInvestigationConclusion, startFindingInvestigation, suppressRepositoryFinding, syncFindingIssue } from "../repositories/findingRepository.js";
import { excludePortfolioRepository, getPortfolioControlPlane, listPortfolioRepositories, reconcilePortfolioRepositories, restorePortfolioRepository } from "../services/portfolioControlPlane.js";
import { assertFeatureChangesetBudget, featureChangesetOperationSchema, mapBounded } from "./featureChangeset.js";
import { asCompactResult, asResult, asStandardKnowledgeResult, compactDelivery, compactRecord, decodeFile, errorCode, operationRecovery, pageCollection, parseFetchId, type ToolResult } from "./resultHelpers.js";
import { assertFindingProject, assertInvestigationProject, PartialExternalMutationError, requireLink } from "./scopeGuards.js";
import { assertDeliveryWriteAllowed, cleanupDeliveryBranch, deliveryContinuation } from "../services/featureDeliveryFlow.js";
import { createOrResumeAutonomyDelivery } from "../services/autonomyDelivery.js";
import { refreshDeliveryFromGitea, resolveReconciledProjectContext } from "../services/deliveryReconciliation.js";
const withAudit = async (input: { actor: string; tool: string; projectId?: string; branch?: string; repositoryId?: string; deliveryId?: string }, operation: (link?: LinkedRepository) => Promise<ToolResult>, write = false): Promise<ToolResult> => {
const started = Date.now();
let link: LinkedRepository | undefined;
let externalOperationId: string | undefined;
let externalMutationReturned = false;
let externalOperationFinished = false;
try {
const deliveryRepositoryId = input.projectId && input.deliveryId ? (await getFeatureDelivery(input.projectId, input.deliveryId)).spec.repositoryId : undefined;
link = input.projectId ? await requireLink(input.projectId, write, input.repositoryId ?? deliveryRepositoryId) : undefined;
if (write && link) {
const prepared = await prepareExternalOperation({
projectId: link.projectId, actor: input.actor, action: input.tool,
target: { repository: link.fullName, branch: input.branch },
});
externalOperationId = prepared.operationId;
await markExternalOperationExecuting(externalOperationId);
}
const result = await operation(link);
externalMutationReturned = write;
if (externalOperationId) {
await finishExternalOperation(externalOperationId, { status: "succeeded" });
externalOperationFinished = true;
}
const audit = recordMcpAudit({ operationId: externalOperationId, projectId: link?.projectId, actor: input.actor, toolName: input.tool, repository: link?.fullName, branch: input.branch, outcome: "succeeded", durationMs: Date.now() - started });
if (write) await audit; else await audit.catch(() => undefined);
return result;
} catch (error) {
if (error instanceof PartialExternalMutationError) externalMutationReturned = true;
const code = errorCode(error);
if (externalOperationId && !externalOperationFinished) await finishExternalOperation(externalOperationId, { status: externalMutationReturned ? "unknown" : "failed", errorCode: code }).catch(() => undefined);
await recordMcpAudit({ operationId: externalOperationId, projectId: link?.projectId, actor: input.actor, toolName: input.tool, repository: link?.fullName, branch: input.branch, outcome: ["GITEA_REPOSITORY_READ_ONLY", "MCP_BRANCH_FORBIDDEN", "DEFAULT_BRANCH_WRITE_FORBIDDEN", "SENSITIVE_PATH_FORBIDDEN", "REPOSITORY_PATH_FORBIDDEN"].includes(code) ? "denied" : "failed", errorCode: code, durationMs: Date.now() - started }).catch(() => undefined);
return { content: [{ type: "text", text: `Operation failed: ${code}` }], structuredContent: { error: code, recovery: operationRecovery(code) }, isError: true };
}
};
export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
const server = new McpServer({ name: "ProjectBrain", version: "0.7.1", websiteUrl: env.PROJECTBRAIN_PUBLIC_URL });
const client = getGiteaClient(env);
server.registerResource("ProjectBrain workspace", PROJECTBRAIN_WORKSPACE_URI, {
title: "ProjectBrain workspace",
description: "Interactive project-memory, review, and feature-delivery workspace.",
mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE,
_meta: {
ui: {
prefersBorder: true,
domain: env.PROJECTBRAIN_PUBLIC_URL,
csp: { connectDomains: [], resourceDomains: [] },
},
"openai/widgetDescription": "Shows durable ProjectBrain context, pending reviews, and active feature deliveries.",
},
}, async () => ({ contents: [{
uri: PROJECTBRAIN_WORKSPACE_URI,
mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE,
text: PROJECTBRAIN_WORKSPACE_HTML,
_meta: {
ui: {
prefersBorder: true,
domain: env.PROJECTBRAIN_PUBLIC_URL,
csp: { connectDomains: [], resourceDomains: [] },
},
"openai/widgetDescription": "Shows durable ProjectBrain context, pending reviews, and active feature deliveries.",
},
}] }));
server.registerResource("ProjectBrain repository analysis", PROJECTBRAIN_ANALYSIS_WIDGET_URI, {
title: "ProjectBrain repository analysis",
description: "Compact progress and result summary for a durable repository-analysis job.",
mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE,
_meta: {
ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } },
"openai/widgetDescription": "Shows bounded progress for a server-side ProjectBrain repository analysis.",
},
}, async () => ({ contents: [{
uri: PROJECTBRAIN_ANALYSIS_WIDGET_URI,
mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE,
text: PROJECTBRAIN_ANALYSIS_WIDGET_HTML,
_meta: {
ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } },
"openai/widgetDescription": "Shows bounded progress for a server-side ProjectBrain repository analysis.",
},
}] }));
server.registerTool("list_projects", {
title: "List ProjectBrain projects", description: "Use this to choose a ProjectBrain project. Results are paginated so the complete portfolio is never returned to the browser at once.", inputSchema: { cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ cursor, page_size }) => withAudit({ actor, tool: "list_projects" }, async () => {
const page = pageCollection(await listProjects(), Number.parseInt(cursor, 10), page_size);
return asCompactResult({ projects: page.items, total: page.total, nextCursor: page.nextCursor }, `${page.items.length} project(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
}));
server.registerTool("list_project_repositories", {
title: "List ProjectBrain repositories", description: "Use this to find a linked Gitea repository and access mode. Results are paginated for browser-safe portfolio growth.", inputSchema: { cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ cursor, page_size }) => withAudit({ actor, tool: "list_project_repositories" }, async () => {
const page = pageCollection(await listLinkedRepositories(), Number.parseInt(cursor, 10), page_size);
return asCompactResult({ repositories: page.items, total: page.total, nextCursor: page.nextCursor }, `${page.items.length} repositorykoppeling(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
}));
server.registerTool("get_portfolio_control_plane", {
title: "Get ProjectBrain portfolio control plane",
description: "Use this first whenever the user asks what needs attention across projects. It returns one recommended next action, a short actionable queue, live notifications, repository/project coverage and compact analysis state without loading repository contents.",
inputSchema: {},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async () => withAudit({ actor, tool: "get_portfolio_control_plane" }, async () => {
const { repositories: _repositories, projects: _projects, ...summary } = await getPortfolioControlPlane(env);
return asCompactResult(summary as unknown as Record<string, unknown>, `Portfolio geladen: volgende actie is '${summary.nextAction.label}'. ${summary.coverage.onboardedRepositories}/${summary.coverage.visibleRepositories} zichtbare repositories zijn gekoppeld; ${summary.findings.active} actieve finding(s).`);
}));
server.registerTool("list_portfolio_repositories", {
title: "List ProjectBrain portfolio repositories",
description: "List one bounded page of Gitea repositories and their durable ProjectBrain onboarding status. Use get_portfolio_control_plane first and paginate only when repository-level detail is needed.",
inputSchema: { status: z.enum(["discovered", "onboarded", "excluded", "unavailable"]).optional(), cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async ({ status, cursor, page_size }) => withAudit({ actor, tool: "list_portfolio_repositories" }, async () => {
const repositories = (await listPortfolioRepositories(env)).filter((repository) => !status || repository.status === status);
const page = pageCollection(repositories, Number.parseInt(cursor, 10), page_size);
return asCompactResult(page as unknown as Record<string, unknown>, `${page.items.length} repositoryrecords geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
}));
server.registerTool("reconcile_portfolio_repositories", {
title: "Reconcile ProjectBrain portfolio repositories",
description: "Preview or apply idempotent onboarding for newly discovered Gitea repositories. Applying creates or matches a ProjectBrain project, configures the signed webhook, grants the available read/write mode and enables conservative critical-finding issue automation.",
inputSchema: { apply: z.boolean().default(false), repository_full_names: z.array(z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/)).max(50).optional() },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
_meta: { "openai/toolInvocation/invoking": "Portfolio reconciliëren…", "openai/toolInvocation/invoked": "Portfolio gereconcilieerd" },
}, async ({ apply, repository_full_names }) => withAudit({ actor, tool: "reconcile_portfolio_repositories" }, async () => {
const result = await reconcilePortfolioRepositories(env, actor, { dryRun: !apply, repositoryFullNames: repository_full_names });
return asCompactResult(result as unknown as Record<string, unknown>, apply ? `${result.onboardedCount} repository(s) gekoppeld; ${result.failedCount} mislukt.` : `${result.candidateCount} repository(s) klaar voor onboarding. Roep opnieuw aan met apply=true na bevestiging.`);
}));
server.registerTool("set_portfolio_repository_exclusion", {
title: "Set ProjectBrain repository onboarding exclusion",
description: "Exclude a discovered repository from current and future automatic onboarding, or restore it. Excluding an already onboarded repository is refused; unlinking remains a separate owner operation.",
inputSchema: { repository_id: z.string().min(1), excluded: z.boolean(), reason: z.string().trim().min(3).max(2_000).optional() },
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
}, async ({ repository_id, excluded, reason }) => withAudit({ actor, tool: "set_portfolio_repository_exclusion" }, async () => {
if (excluded && !reason) throw new Error("PORTFOLIO_EXCLUSION_REASON_REQUIRED");
const repository = excluded ? await excludePortfolioRepository(repository_id, reason!) : await restorePortfolioRepository(repository_id);
return asCompactResult({ repository } as unknown as Record<string, unknown>, `${repository.fullName} is ${excluded ? "uitgesloten van" : "hersteld voor"} automatische onboarding.`);
}));
server.registerTool("search", {
title: "Search ProjectBrain repositories", description: "Search code in all Gitea repositories linked to ProjectBrain. Use fetch with a returned id to read the complete file.",
inputSchema: { query: z.string().trim().min(2).max(200).describe("Plain-text code search query") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ query }) => withAudit({ actor, tool: "search" }, async () => {
const links = await listLinkedRepositories();
const batches = await mapBounded(links, 6, async (link) => {
try {
const response = await client.searchCode(link.owner, link.repository, query, link.defaultBranch);
const rows = Array.isArray(response) ? response : response.data ?? [];
return rows.map((row) => ({ id: `gitea://${encodeURIComponent(link.projectSlug)}/${encodeURIComponent(link.defaultBranch)}/${encodeURIComponent(row.path)}`, title: `${link.fullName}: ${row.path}`, url: row.html_url }));
} catch { return []; }
});
const results = batches.flat().slice(0, 50);
return asStandardKnowledgeResult({ results });
}));
server.registerTool("fetch", {
title: "Fetch repository file", description: "Fetch a complete repository file by the canonical gitea:// id returned by search.",
inputSchema: { id: z.string().startsWith("gitea://").describe("Canonical gitea://project/ref/path identifier") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ id }) => {
let parsed: ReturnType<typeof parseFetchId>;
try { parsed = parseFetchId(id); } catch (error) { return asResult({ error: errorCode(error) }, "Invalid fetch id"); }
return withAudit({ actor, tool: "fetch", projectId: parsed.projectId, branch: parsed.ref }, async (link) => {
const path = assertReadablePath(parsed.path);
const file = await client.getContents(link!.owner, link!.repository, path, parsed.ref);
if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
return asStandardKnowledgeResult({ id, title: `${link!.fullName}: ${path}`, text: decodeFile(file), url: file.html_url, metadata: { projectId: link!.projectSlug, repository: link!.fullName, path, ref: parsed.ref, sha: file.sha, size: file.size } });
});
});
server.registerTool("get_project_context", {
title: "Get ProjectBrain project context", description: "Use this first for one known project. It returns bounded approved context plus a stable resume state and one recommended next action; paginate only when resolver.nextCursor is present.",
inputSchema: { project_id: z.string().min(1).describe("ProjectBrain project id or slug"), focus: z.string().trim().max(500).optional(), max_items: z.number().int().min(1).max(25).default(10), cursor: z.string().regex(/^\d+$/).optional(), character_budget: z.number().int().min(4000).max(80000).default(24000) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, focus, max_items, cursor, character_budget }) => withAudit({ actor, tool: "get_project_context", projectId: project_id }, async (link) => {
const context = await resolveReconciledProjectContext(client, link!.projectId, { focus, maxItems: max_items, cursor, characterBudget: character_budget });
return asCompactResult(context as unknown as Record<string, unknown>, `Projectcontext geladen. Volgende actie: ${String((context.resume.nextAction as Record<string, unknown>).type)}.`);
}));
server.registerTool("render_project_workspace", {
title: "Open ProjectBrain workspace",
description: "Render an interactive overview of a project's durable context, repository, pending reviews, and feature-delivery work.",
inputSchema: { project_id: z.string().min(1).describe("ProjectBrain project id or slug") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
_meta: { ui: { resourceUri: PROJECTBRAIN_WORKSPACE_URI }, "openai/outputTemplate": PROJECTBRAIN_WORKSPACE_URI },
}, async ({ project_id }) => withAudit({ actor, tool: "render_project_workspace", projectId: project_id }, async (link) => {
const project = await prisma.project.findUnique({ where: { id: link!.projectId }, select: { id: true, slug: true, name: true, description: true, updatedAt: true } });
const [contextPackCount, deliveries, reviews] = await Promise.all([
prisma.contextPack.count({ where: { projectId: link!.projectId, deletedAt: null } }),
listFeatureDeliveries(project_id),
listReviewItemsFromDatabase(project_id, { status: ["pending"] }, { page: 1, pageSize: 20 }),
]);
return asResult({ projectId: link!.projectSlug, project, repository: link, contextPackCount, featureDeliveries: deliveries.slice(0, 12).map(compactDelivery), pendingReviews: (reviews?.items ?? []).slice(0, 12).map(compactRecord) }, "ProjectBrain workspace");
}));
server.registerTool("get_project_modules", {
title: "Get ProjectBrain module state", description: "Load the current brainstorms, knowledge, documentation, memory rules, context packs, feature plans and pending reviews for a project. Use this to understand what already exists before adding or changing project knowledge.",
inputSchema: { project_id: z.string().min(1), max_items: z.number().int().min(1).max(25).default(10), cursor: z.string().regex(/^\d+$/).default("0") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, max_items, cursor }) => withAudit({ actor, tool: "get_project_modules", projectId: project_id }, async () => {
const [brainstorms, knowledge, documentation, memoryRules, contextPacks, deliveries, reviews] = await Promise.all([
listBrainstormsForProject(project_id),
listKnowledgeForProject(project_id),
listDocumentationForProject(project_id),
listMemoryRulesForProject(project_id),
listContextPacksForProject(project_id),
listFeatureDeliveries(project_id),
listReviewItemsFromDatabase(project_id, { status: ["pending"] }, { page: 1, pageSize: 25 }),
]);
const offset = Number.parseInt(cursor, 10);
const compactPage = (items: unknown[]) => pageCollection(items.map(compactRecord), offset, max_items);
return asCompactResult({ cursor, pageSize: max_items, brainstorms: compactPage(brainstorms), knowledge: compactPage(knowledge), documentation: compactPage(documentation), memoryRules: compactPage(memoryRules), contextPacks: compactPage(contextPacks ?? []), featureDeliveries: compactPage(deliveries), pendingReviews: compactPage(reviews?.items ?? []) }, "Compact, paginated ProjectBrain module inventory");
}));
server.registerTool("start_repository_analysis", {
title: "Start server-side repository analysis",
description: "Use this when a repository or portfolio analysis would otherwise require many synchronous Gitea calls. It queues one durable, bounded server-side job and immediately returns its job id.",
inputSchema: {
project_ids: z.array(z.string().trim().min(1)).max(200).optional(),
repository_ids: z.array(z.string().trim().min(1)).max(200).optional(),
include_branches: z.boolean().default(true),
include_pull_requests: z.boolean().default(true),
include_actions: z.boolean().default(true),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
_meta: { "openai/toolInvocation/invoking": "Analyse in wachtrij plaatsen…", "openai/toolInvocation/invoked": "Analysejob aangemaakt" },
}, async ({ project_ids, repository_ids, include_branches, include_pull_requests, include_actions }) => withAudit({ actor, tool: "start_repository_analysis" }, async () => {
const result = await createPortfolioAnalysisJob(env, actor, { projectIds: project_ids, repositoryIds: repository_ids, includeBranches: include_branches, includePullRequests: include_pull_requests, includeActions: include_actions });
const job = result.job as Record<string, unknown>;
return asCompactResult({ job, deduplicated: result.deduplicated }, `Analysejob ${job.id} staat ${job.status === "queued" ? "in de wachtrij" : "al actief"}. Gebruik get_analysis_job voor de voortgang.`);
}));
server.registerTool("list_analysis_jobs", {
title: "List repository analysis jobs",
description: "Use this to find recent server-side repository-analysis jobs created by the current caller.",
inputSchema: { limit: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ limit }) => withAudit({ actor, tool: "list_analysis_jobs" }, async () => {
const jobs = await listAnalysisJobs(actor, limit);
return asCompactResult({ jobs }, `${jobs.length} analysejob(s) gevonden.`);
}));
server.registerTool("get_analysis_job", {
title: "Get repository analysis status",
description: "Use this to check compact progress for one analysis job. Respect pollAfterMs and repeat only while terminal=false; it never returns per-repository details.",
inputSchema: { job_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ job_id }) => withAudit({ actor, tool: "get_analysis_job" }, async () => {
const job = await getAnalysisJob(actor, job_id) as Record<string, unknown>;
return asCompactResult({ job }, `Analysejob ${job.id}: ${job.status}, ${job.progressCurrent}/${job.progressTotal}.`);
}));
server.registerTool("get_analysis_job_results", {
title: "Get paginated repository analysis results",
description: "Use this only after get_analysis_job reports progress or completion. It returns a bounded page of repository results, never the entire portfolio at once.",
inputSchema: { job_id: z.string().min(1), cursor: z.string().regex(/^\d+$/).optional(), page_size: z.number().int().min(1).max(10).default(5), character_budget: z.number().int().min(4000).max(32000).default(24000) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ job_id, cursor, page_size, character_budget }) => withAudit({ actor, tool: "get_analysis_job_results" }, async () => {
const result = await getAnalysisJobResults(actor, job_id, { cursor, pageSize: page_size, characterBudget: character_budget });
return asCompactResult(result as unknown as Record<string, unknown>, `${result.items.length} begrensde repositoryresultaten geladen.${result.nextCursor ? ` Volgende cursor: ${result.nextCursor}.` : ""}`);
}));
server.registerTool("cancel_analysis_job", {
title: "Cancel repository analysis",
description: "Use this when the user wants to stop a queued or running ProjectBrain repository-analysis job. Completed work remains available until retention expiry.",
inputSchema: { job_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ job_id }) => withAudit({ actor, tool: "cancel_analysis_job" }, async () => {
const job = await cancelAnalysisJob(actor, job_id) as Record<string, unknown>;
return asCompactResult({ job }, `Annulering voor analysejob ${job.id} is verwerkt.`);
}));
server.registerTool("render_analysis_job", {
title: "Open repository analysis progress",
description: "Render the compact progress card for an existing analysis job. Always call start_repository_analysis or get_analysis_job first.",
inputSchema: { job_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
_meta: { ui: { resourceUri: PROJECTBRAIN_ANALYSIS_WIDGET_URI }, "openai/outputTemplate": PROJECTBRAIN_ANALYSIS_WIDGET_URI },
}, async ({ job_id }) => withAudit({ actor, tool: "render_analysis_job" }, async () => {
const job = await getAnalysisJob(actor, job_id) as Record<string, unknown>;
return asCompactResult({ job }, `Voortgangskaart voor analysejob ${job.id}.`);
}));
server.registerTool("list_repository_findings", {
title: "List durable repository findings",
description: "Use this when the user wants new, active, regressed, resolved, or suppressed ProjectBrain findings. Results are risk-ranked and paginated.",
inputSchema: {
project_id: z.string().min(1).optional(),
statuses: z.array(z.enum(["new", "triaged", "issue_open", "investigating", "planned", "fixing", "verifying", "resolved", "regressed", "suppressed"])).max(10).optional(),
severities: z.array(z.enum(["info", "warning", "critical"])).max(3).optional(),
cursor: z.string().regex(/^\d+$/).optional(),
limit: z.number().int().min(1).max(50).default(20),
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, statuses, severities, cursor, limit }) => withAudit({ actor, tool: "list_repository_findings", projectId: project_id }, async () => {
const result = await listRepositoryFindings({ projectId: project_id, statuses, severities, cursor, limit });
return asCompactResult(result as unknown as Record<string, unknown>, `${result.items.length} duurzame bevinding(en) geladen.${result.nextCursor ? ` Volgende cursor: ${result.nextCursor}.` : ""}`);
}));
server.registerTool("get_repository_finding", {
title: "Get repository finding",
description: "Use this before creating an issue, starting an investigation, suppressing a finding, or planning a fix. Returns its lifecycle, occurrences, issue and recent investigations.",
inputSchema: { finding_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ finding_id }) => withAudit({ actor, tool: "get_repository_finding" }, async () => asCompactResult({ finding: await getRepositoryFinding(finding_id) } as unknown as Record<string, unknown>, `Bevinding ${finding_id} geladen.`)));
server.registerTool("create_finding_issue", {
title: "Create or synchronize Gitea issue for finding",
description: "Use this only when the user wants a durable Gitea issue for an existing ProjectBrain finding. The operation is idempotent: retries update or reopen the same marked issue instead of creating duplicates.",
inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
_meta: { "openai/toolInvocation/invoking": "Gitea-issue synchroniseren…", "openai/toolInvocation/invoked": "Gitea-issue gesynchroniseerd" },
}, async ({ project_id, finding_id }) => {
const finding = await assertFindingProject(project_id, finding_id);
return withAudit({ actor, tool: "create_finding_issue", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => {
const result = await syncFindingIssue(env, finding_id, actor);
return asCompactResult({ finding: result.finding, issue: result.issue } as unknown as Record<string, unknown>, `Gitea-issue #${result.issue.number} is gekoppeld aan bevinding ${finding_id}.`);
}, true);
});
server.registerTool("start_finding_investigation", {
title: "Start finding investigation",
description: "Use this when the user asks ChatGPT to investigate a ProjectBrain finding or its Gitea issue. ProjectBrain assembles one bounded evidence pack server-side and reuses an active investigation on retries.",
inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async ({ project_id, finding_id }) => {
const finding = await assertFindingProject(project_id, finding_id);
return withAudit({ actor, tool: "start_finding_investigation", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => {
const investigation = await startFindingInvestigation(env, finding_id, actor);
return asCompactResult({ investigation } as unknown as Record<string, unknown>, `Onderzoek ${investigation.id} is klaar voor analyse.`);
});
});
server.registerTool("get_finding_investigation", {
title: "Get finding investigation",
description: "Use this to load the bounded evidence pack, conclusion and linked delivery for one ProjectBrain finding investigation.",
inputSchema: { investigation_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ investigation_id }) => withAudit({ actor, tool: "get_finding_investigation" }, async () => asCompactResult({ investigation: await getFindingInvestigation(investigation_id) } as unknown as Record<string, unknown>, `Onderzoek ${investigation_id} geladen.`)));
server.registerTool("save_finding_investigation", {
title: "Save finding investigation conclusion",
description: "Use this after examining the evidence pack. Persists a concise diagnosis, root cause, proposed file-level changes, confidence and conclusion without starting repository changes.",
inputSchema: {
project_id: z.string().min(1), investigation_id: z.string().min(1), diagnosis: z.string().trim().min(8).max(12_000), root_cause: z.string().trim().min(8).max(12_000),
proposed_changes: z.array(z.string().trim().min(1).max(2_000)).min(1).max(50), confidence: z.number().int().min(0).max(100), conclusion: z.string().trim().min(8).max(8_000),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, investigation_id, diagnosis, root_cause, proposed_changes, confidence, conclusion }) => {
const investigation = await assertInvestigationProject(project_id, investigation_id);
return withAudit({ actor, tool: "save_finding_investigation", projectId: project_id, repositoryId: investigation.finding.repositoryLinkId ?? undefined }, async () => asCompactResult({ investigation: await saveFindingInvestigationConclusion(investigation_id, { diagnosis, rootCause: root_cause, proposedChanges: proposed_changes, confidence, conclusion }) } as unknown as Record<string, unknown>, `Onderzoeksconclusie ${investigation_id} opgeslagen.`));
});
server.registerTool("suppress_repository_finding", {
title: "Suppress repository finding",
description: "Use this only when the user explicitly accepts or defers a finding. A reason is mandatory and an optional expiry lets future scans reactivate it.",
inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1), reason: z.string().trim().min(3).max(2_000), until: z.string().datetime().optional() },
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, finding_id, reason, until }) => {
const finding = await assertFindingProject(project_id, finding_id);
return withAudit({ actor, tool: "suppress_repository_finding", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => asCompactResult({ finding: await suppressRepositoryFinding(finding_id, reason, until ? new Date(until) : undefined) } as unknown as Record<string, unknown>, `Bevinding ${finding_id} is onderdrukt.`));
});
server.registerTool("save_brainstorm", {
title: "Save ChatGPT brainstorm", description: "Persist an idea exploration in ProjectBrain's Brainstorm module. Use this for meaningful alternatives, trade-offs and next actions; use create_feature_delivery only after the user has chosen a direction.",
inputSchema: {
project_id: z.string().min(1), title: z.string().trim().min(3).max(160), prompt: z.string().trim().min(3).max(8000),
context_summary: z.string().trim().min(3).max(8000), ideas: z.array(z.object({ id: z.string().min(1), title: z.string().min(1), description: z.string().min(1), score: z.number().min(0).max(100).optional() })).max(50).default([]),
outcomes: z.array(z.string().min(1).max(2000)).max(50).default([]), selected_idea_ids: z.array(z.string().min(1)).max(50).default([]), next_actions: z.array(z.string().min(1).max(1000)).max(50).default([]),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, prompt, context_summary, ideas, outcomes, selected_idea_ids, next_actions }) => withAudit({ actor, tool: "save_brainstorm", projectId: project_id }, async () => asResult(await createBrainstormForProject(project_id, {
title, prompt, scope: "project", mode: "chatgpt", status: selected_idea_ids.length ? "selected" : "exploring", generatedBy: "chatgpt_mcp", contextSummary: context_summary,
ideas, outcomes: outcomes.map((summary, index) => ({ id: `outcome-${index + 1}`, summary })), selectedIdeaIds: selected_idea_ids, nextActions: next_actions,
}), "Brainstorm saved in ProjectBrain")));
server.registerTool("save_project_knowledge", {
title: "Save project knowledge", description: "Save a reusable fact, finding or technical explanation to ProjectBrain's Knowledge module. New knowledge remains pending for human review.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), content: z.string().trim().min(3).max(30000), category: z.string().trim().min(1).max(80).default("Technical"), confidence: z.number().int().min(0).max(100).default(75) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, content, category, confidence }) => withAudit({ actor, tool: "save_project_knowledge", projectId: project_id }, async () => asResult(await createKnowledgeItem(project_id, { title, content, category, confidence, source: "ChatGPT via ProjectBrain MCP" }), "Pending knowledge saved")));
server.registerTool("save_documentation_draft", {
title: "Save documentation draft", description: "Write a documentation draft into ProjectBrain. The draft remains pending until the user reviews it in the Documentation module.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), content: z.string().trim().min(3).max(50000), kind: z.string().trim().min(1).max(80).default("design document") },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, content, kind }) => withAudit({ actor, tool: "save_documentation_draft", projectId: project_id }, async () => asResult(await createDocumentationItem(project_id, { title, content, kind, status: "draft", approvalState: "pending" }), "Documentation draft saved")));
server.registerTool("save_memory_rule", {
title: "Save project memory rule", description: "Save an enduring instruction or do-not-break rule in ProjectBrain. New rules remain pending for human review.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), rule: z.string().trim().min(3).max(8000), scope: z.enum(["global", "module", "feature", "prompt_only"]).default("global"), category: z.enum(["UX", "Technical", "Architecture", "Do Not Break", "Naming", "Workflow", "Security"]).default("Technical"), severity: z.enum(["info", "important", "critical", "do_not_break"]).default("important") },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, rule, scope, category, severity }) => withAudit({ actor, tool: "save_memory_rule", projectId: project_id }, async () => asResult(await createMemoryRuleItem(project_id, { title, rule, scope, category, severity, includeInContextPacks: true }), "Pending memory rule saved")));
server.registerTool("create_project_context_pack", {
title: "Create project context pack", description: "Create a fresh ProjectBrain context pack from the project's approved DNA, features, evidence, memory and architecture before a substantial planning or implementation task.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160).optional() },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title }) => withAudit({ actor, tool: "create_project_context_pack", projectId: project_id }, async () => asResult(await createContextPackForProject(project_id, { sourceType: "workspace", title, includeDNA: true, includeFeatures: true, includeEvidence: true, includeMemoryRules: true, includeRisks: true, includeRegressions: true, includeArchitecture: true }), "Context pack created")));
server.registerTool("record_execution_feedback", {
title: "Record execution feedback",
description: "Import the outcome of a completed coding or automation run into ProjectBrain's Review Queue. Feedback stays pending until a human accepts it as project truth.",
inputSchema: {
project_id: z.string().min(1), codex_task_id: z.string().min(1), source_run: z.string().trim().min(1).max(240),
summary: z.string().trim().min(3).max(8000), output_text: z.string().max(100_000).default(""),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, codex_task_id, source_run, summary, output_text }) => withAudit({ actor, tool: "record_execution_feedback", projectId: project_id }, async () => {
const result = await importCodexOutputInDatabase(project_id, codex_task_id, { sourceRun: source_run, summary, outputText: output_text });
if (!result) throw new Error("PROJECT_NOT_FOUND");
if (result === "CODEX_TASK_NOT_FOUND") throw new Error(result);
return asResult(result, "Execution feedback imported for human review");
}));
server.registerTool("list_feature_deliveries", {
title: "List feature deliveries", description: "Use this to find a delivery id or compact status. Results are paginated summaries; call get_feature_delivery only for the selected delivery's full plan and runtime evidence.",
inputSchema: { project_id: z.string().min(1), cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, cursor, page_size }) => withAudit({ actor, tool: "list_feature_deliveries", projectId: project_id }, async () => {
const deliveries = (await listFeatureDeliveries(project_id)).map(compactDelivery);
const page = pageCollection(deliveries, Number.parseInt(cursor, 10), page_size);
return asCompactResult(page as unknown as Record<string, unknown>, `${page.items.length} deliverysamenvatting(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
}));
server.registerTool("get_feature_delivery", {
title: "Get feature delivery", description: "Use this before continuing a feature to load its current scope, authorization state, acceptance criteria, implementation plan, branch, progress, validations, and pull request. If approvalState is approved, ProjectBrain policy has already authorized execution; do not ask for redundant approval.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "get_feature_delivery", projectId: project_id }, async () => {
const delivery = await refreshDeliveryFromGitea(client, project_id, delivery_id);
return asResult({ ...delivery, continuation: deliveryContinuation(delivery) }, "Delivery reconciled with Gitea; follow continuation.");
}));
const featurePlanSchema = {
project_id: z.string().min(1), title: z.string().trim().min(3).max(160), intent: z.string().trim().min(8).max(4000),
priority: z.enum(["critical", "important", "normal", "low"]).default("normal"), problem: z.string().trim().min(8).max(8000),
desired_outcome: z.string().trim().min(8).max(8000), users: z.array(z.string().min(1).max(300)).max(30).default([]),
goals: z.array(z.string().min(1).max(1000)).min(1).max(50), non_goals: z.array(z.string().min(1).max(1000)).max(50).default([]),
acceptance_criteria: z.array(z.string().min(1).max(1000)).min(1).max(100), implementation_plan: z.array(z.string().min(1).max(2000)).min(1).max(100),
risks: z.array(z.string().min(1).max(1000)).max(50).default([]), validation_commands: z.array(z.string().min(1).max(500)).min(1).max(30),
risk_level: z.enum(["low", "medium", "high", "destructive"]).default("medium").describe("Classify execution risk. low/medium reversible work is policy-authorized automatically; high/destructive requires a real user decision before repository writes."),
};
server.registerTool("create_delivery_from_finding", {
title: "Create delivery plan from finding investigation",
description: "Use this only after save_finding_investigation. Creates a reviewable ProjectBrain delivery linked to the finding and investigation; it never approves the plan or starts code changes.",
inputSchema: { investigation_id: z.string().min(1), ...featurePlanSchema },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, investigation_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands }) => {
const investigation = await assertInvestigationProject(project_id, investigation_id);
return withAudit({ actor, tool: "create_delivery_from_finding", projectId: project_id, repositoryId: investigation.finding.repositoryLinkId ?? undefined }, async () => {
const result = await createDeliveryFromFindingInvestigation(investigation_id, {
title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals,
acceptanceCriteria: acceptance_criteria, implementationPlan: implementation_plan, risks, validationCommands: validation_commands,
}, actor);
return asCompactResult(result as unknown as Record<string, unknown>, `Delivery ${result.delivery.id} is aangemaakt en wacht op goedkeuring in ProjectBrain.`);
});
});
server.registerTool("verify_finding_resolution", {
title: "Queue post-merge finding verification",
description: "Use this after the finding-linked pull request is merged. Queues a bounded repository reanalysis; ProjectBrain closes the issue only when the original finding is absent.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "verify_finding_resolution", projectId: project_id, deliveryId: delivery_id }, async () => {
const verification = await queueFindingVerificationForDelivery(env, project_id, delivery_id);
if (!verification) throw new Error("FINDING_VERIFICATION_NOT_AVAILABLE");
return asCompactResult(verification as unknown as Record<string, unknown>, `Gerichte verificatiejob ${(verification.job as Record<string, unknown>).id} staat in de wachtrij.`);
}));
server.registerTool("create_feature_delivery", {
title: "Create feature delivery plan",
description: "Create the bounded execution plan for a user request. Classify risk explicitly. ProjectBrain auto-authorizes low/medium reversible work; high/destructive work remains pending and is surfaced under Jouw aandacht. Creating the plan never writes repository files by itself.",
inputSchema: featurePlanSchema,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands, risk_level }) => withAudit({ actor, tool: "create_feature_delivery", projectId: project_id }, async () => {
const delivery = await createOrResumeAutonomyDelivery(project_id, {
title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals, acceptanceCriteria: acceptance_criteria,
implementationPlan: implementation_plan, risks, validationCommands: validation_commands, riskLevel: risk_level,
}, actor);
return asResult(delivery, delivery.approvalState === "approved"
? "Feature delivery policy-authorized. Continue autonomously with start_feature_delivery; do not ask the user for redundant approval."
: "Feature delivery requires a real user decision. Stop repository writes and surface the decision in ProjectBrain under Jouw aandacht.");
}));
server.registerTool("update_feature_delivery", {
title: "Update feature delivery plan",
description: "Refine an unstarted feature plan and re-evaluate its risk policy. Low/medium reversible work is policy-authorized; high/destructive work returns to a real user-decision state. Active or completed plans are locked.",
inputSchema: { delivery_id: z.string().min(1), ...featurePlanSchema },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands, risk_level }) => withAudit({ actor, tool: "update_feature_delivery", projectId: project_id }, async () => {
const delivery = await updateFeatureDelivery(project_id, delivery_id, {
title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals, acceptanceCriteria: acceptance_criteria,
implementationPlan: implementation_plan, risks, validationCommands: validation_commands, riskLevel: risk_level,
}, actor);
return asResult(delivery, delivery.approvalState === "approved"
? "Feature delivery updated and policy-authorized. Continue autonomously without an extra approval round."
: "Feature delivery updated and now requires a real user decision before repository execution.");
}));
server.registerTool("list_repository_tree", {
title: "List repository tree", description: "List files and directories at a repository path and ref.",
inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), path: z.string().default(""), ref: z.string().optional() },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, repository_id, path, ref }) => withAudit({ actor, tool: "list_repository_tree", projectId: project_id, repositoryId: repository_id, branch: ref }, async (link) => {
const safePath = path ? assertReadablePath(path) : "";
const resolvedRef = ref ?? link!.defaultBranch;
const contents = await client.getContents(link!.owner, link!.repository, safePath, resolvedRef);
const entries = (Array.isArray(contents) ? contents : [contents]).map(({ content: _content, ...entry }) => entry);
return asResult({ projectId: link!.projectSlug, repository: link!.fullName, path: safePath, ref: resolvedRef, entries }, "Repository tree");
}));
server.registerTool("get_file", {
title: "Read repository file", description: "Read a text file from a linked repository at a branch, tag, or commit.",
inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), path: z.string().min(1), ref: z.string().optional() },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, repository_id, path, ref }) => withAudit({ actor, tool: "get_file", projectId: project_id, repositoryId: repository_id, branch: ref }, async (link) => {
const safePath = assertReadablePath(path); const resolvedRef = ref ?? link!.defaultBranch;
const file = await client.getContents(link!.owner, link!.repository, safePath, resolvedRef);
if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
return asResult({ projectId: link!.projectSlug, repository: link!.fullName, path: safePath, ref: resolvedRef, sha: file.sha, size: file.size, content: decodeFile(file), htmlUrl: file.html_url }, "Repository file");
}));
server.registerTool("list_branches", {
title: "List repository branches", description: "List branches and protection status for a linked repository.", inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional() },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, repository_id }) => withAudit({ actor, tool: "list_branches", projectId: project_id, repositoryId: repository_id }, async (link) => asResult({ defaultBranch: link!.defaultBranch, branches: await client.listBranches(link!.owner, link!.repository) }, "Repository branches")));
server.registerTool("list_pull_requests", {
title: "List pull requests", description: "List pull requests for a linked repository.", inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), state: z.enum(["open", "closed", "all"]).default("open") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, repository_id, state }) => withAudit({ actor, tool: "list_pull_requests", projectId: project_id, repositoryId: repository_id }, async (link) => asResult({ pullRequests: await client.listPullRequests(link!.owner, link!.repository, state) }, "Pull requests")));
server.registerTool("start_feature_delivery", {
title: "Start authorized feature delivery",
description: "Start an isolated non-default delivery branch only when ProjectBrain policy already reports the delivery as approved. Low/medium work can arrive pre-approved automatically; high/destructive work cannot start until its real user decision is recorded. Never ask for a redundant approval when approvalState is already approved.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), repository_id: z.string().min(1).optional(), branch: z.string().min(1).max(300).describe("Unique valid non-default Git branch") },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, delivery_id, repository_id, branch }) => withAudit({ actor, tool: "start_feature_delivery", projectId: project_id, repositoryId: repository_id, deliveryId: delivery_id, branch }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
if (delivery.spec.branch === safeBranch && ["executing", "validating", "pull_request_open", "merged", "verified", "released"].includes(delivery.spec.status)) return asResult(delivery, "Feature delivery already started");
if (delivery.approvalState !== "approved" || delivery.spec.status !== "approved") throw new Error("FEATURE_DELIVERY_APPROVAL_REQUIRED");
const base = await client.getBranch(link!.owner, link!.repository, link!.defaultBranch);
try { await client.createBranch(link!.owner, link!.repository, safeBranch, link!.defaultBranch); }
catch (error) {
if (!(error instanceof GiteaApiError && error.status === 409)) throw error;
await client.getBranch(link!.owner, link!.repository, safeBranch);
}
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
return asResult(await startFeatureDelivery(project_id, delivery_id, safeBranch, actor, { repositoryLinkId: link!.id, baseCommitSha: base.commit.id, headSha: head.commit.id }), "Authorized feature delivery started");
}, true));
server.registerTool("write_feature_file", {
title: "Write approved feature file", description: "Use this to create or update one text file for an active approved feature delivery. The delivery must own the supplied ChatGPT/ProjectBrain branch. Pass the SHA returned by get_file when updating an existing file.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(9), path: z.string().min(1), content: z.string(), message: z.string().trim().min(3).max(200), expected_sha: z.string().min(1).max(200).optional(), progress_note: z.string().max(2000).optional() },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, delivery_id, branch, path, content, message, expected_sha, progress_note }) => withAudit({ actor, tool: "write_feature_file", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!);
const safePath = assertWritablePath(path); assertFileSize(content);
let existing: GiteaContent | undefined;
try { const value = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (!Array.isArray(value)) existing = value; }
catch (error) { if (!(error instanceof GiteaApiError && error.status === 404)) throw error; }
assertExpectedFileSha(expected_sha, existing?.sha);
const result = existing ? await client.updateFile(link!.owner, link!.repository, safePath, safeBranch, expected_sha!, content, message) : await client.createFile(link!.owner, link!.repository, safePath, safeBranch, content, message);
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
let written: GiteaContent | undefined;
try { const value = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (!Array.isArray(value)) written = value; } catch { /* The commit is still tracked by branch head. */ }
const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: [{ path: safePath, operation: existing ? "update" : "create", beforeSha: existing?.sha, afterSha: written?.sha, commitSha: head.commit.id }], note: progress_note, headSha: head.commit.id }, actor);
return asResult({ repository: link!.fullName, branch: safeBranch, path: safePath, action: existing ? "updated" : "created", result, delivery: progress }, "Feature file committed and progress recorded");
}, true));
server.registerTool("delete_feature_file", {
title: "Delete approved feature file", description: "Delete one file from an active approved feature delivery branch and record that change. This is destructive and requires explicit confirmation.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(9), path: z.string().min(1), message: z.string().trim().min(3).max(200), expected_sha: z.string().min(1).max(200), progress_note: z.string().max(2000).optional() },
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, delivery_id, branch, path, message, expected_sha, progress_note }) => withAudit({ actor, tool: "delete_feature_file", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!);
const safePath = assertWritablePath(path);
const file = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
assertExpectedFileSha(expected_sha, file.sha);
const result = await client.deleteFile(link!.owner, link!.repository, safePath, safeBranch, expected_sha, message);
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: [{ path: safePath, operation: "delete", beforeSha: file.sha, commitSha: head.commit.id }], note: progress_note, headSha: head.commit.id }, actor);
return asResult({ repository: link!.fullName, branch: safeBranch, path: safePath, deleted: true, result, delivery: progress }, "Feature file deleted and progress recorded");
}, true));
server.registerTool("apply_feature_changeset", {
title: "Apply approved feature changeset",
description: "Create, update, and delete up to 25 text files for one approved delivery in a single bounded server-side call. Use this instead of repeated file tools for multi-file work. Every path is preflighted against its expected SHA and the exact branch head before the first write.",
inputSchema: {
project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(1),
expected_head_sha: z.string().regex(/^[0-9a-f]{40,64}$/i), message: z.string().trim().min(3).max(200),
operations: z.array(featureChangesetOperationSchema).min(1).max(25), progress_note: z.string().max(2_000).optional(),
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
_meta: { "openai/toolInvocation/invoking": "Gebudgetteerde changeset toepassen…", "openai/toolInvocation/invoked": "Changeset toegepast" },
}, async ({ project_id, delivery_id, branch, expected_head_sha, message, operations, progress_note }) => withAudit({ actor, tool: "apply_feature_changeset", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!);
const paths = assertFeatureChangesetBudget(operations);
const initialHead = await client.getBranch(link!.owner, link!.repository, safeBranch);
if (initialHead.commit.id !== expected_head_sha) throw new Error("FEATURE_CHANGESET_HEAD_CONFLICT");
const preflight = await mapBounded(operations, 4, async (operation, index) => {
let current: GiteaContent | undefined;
try {
const value = await client.getContents(link!.owner, link!.repository, paths[index], safeBranch);
if (Array.isArray(value)) throw new Error("GITEA_CONTENT_NOT_FILE");
current = value;
} catch (error) {
if (!(error instanceof GiteaApiError && error.status === 404)) throw error;
}
if (operation.operation === "create") assertExpectedFileSha(undefined, current?.sha);
else assertExpectedFileSha(operation.expected_sha, current?.sha);
return current;
});
const preflightHead = await client.getBranch(link!.owner, link!.repository, safeBranch);
if (preflightHead.commit.id !== expected_head_sha) throw new Error("FEATURE_CHANGESET_HEAD_CONFLICT");
const appliedChanges: Array<{ path: string; operation: "create" | "update" | "delete"; beforeSha?: string; afterSha?: string; commitSha?: string }> = [];
try {
for (let index = 0; index < operations.length; index += 1) {
const operation = operations[index]; const path = paths[index]; const before = preflight[index];
if (operation.operation === "create") await client.createFile(link!.owner, link!.repository, path, safeBranch, operation.content, message);
else if (operation.operation === "update") await client.updateFile(link!.owner, link!.repository, path, safeBranch, operation.expected_sha, operation.content, message);
else await client.deleteFile(link!.owner, link!.repository, path, safeBranch, operation.expected_sha, message);
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
let afterSha: string | undefined;
if (operation.operation !== "delete") {
const written = await client.getContents(link!.owner, link!.repository, path, safeBranch);
if (!Array.isArray(written)) afterSha = written.sha;
}
appliedChanges.push({ path, operation: operation.operation, beforeSha: before?.sha, afterSha, commitSha: head.commit.id });
}
} catch {
if (appliedChanges.length) {
const partialHead = await client.getBranch(link!.owner, link!.repository, safeBranch).catch(() => undefined);
await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: appliedChanges, note: `Partial changeset: ${appliedChanges.length}/${operations.length} operations applied before a remote failure.`, headSha: partialHead?.commit.id }, actor).catch(() => undefined);
throw new PartialExternalMutationError("FEATURE_CHANGESET_PARTIAL");
}
throw new Error("FEATURE_CHANGESET_APPLY_FAILED");
}
const finalHead = await client.getBranch(link!.owner, link!.repository, safeBranch);
const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: appliedChanges, note: progress_note, headSha: finalHead.commit.id }, actor);
return asCompactResult({ repository: link!.fullName, deliveryId: progress.id, branch: safeBranch, previousHeadSha: initialHead.commit.id, headSha: finalHead.commit.id, applied: appliedChanges.map(({ path, operation }) => ({ path, operation })) }, `${appliedChanges.length} bestandsbewerkingen server-side toegepast op ${safeBranch}.`);
}, true));
server.registerTool("record_feature_validation", {
title: "Record reported feature validation", description: "Store reported, failed, or not-run checks for an active feature. Reported results are useful progress evidence but cannot authorize a pull request; use sync_feature_validation to verify a successful Gitea Actions run for the current branch head.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), results: z.array(z.object({ command: z.string().min(1).max(500), status: z.enum(["passed", "failed", "not_run"]), summary: z.string().max(2000).optional() })).min(1).max(30), note: z.string().max(4000).optional(), ready_for_review: z.boolean().default(false) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id, results, note, ready_for_review }) => withAudit({ actor, tool: "record_feature_validation", projectId: project_id }, async () => asResult(await recordFeatureDeliveryProgress(project_id, delivery_id, { validationResults: results, note, readyForReview: ready_for_review }, actor), "Feature validation recorded")));
server.registerTool("get_feature_validation_runs", {
title: "Get feature validation runs", description: "Inspect Gitea Actions runs for the active feature branch and identify whether the current branch head has completed successfully.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "get_feature_validation_runs", projectId: project_id, deliveryId: delivery_id }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
if (!delivery.spec.branch) throw new Error("FEATURE_DELIVERY_NOT_STARTED");
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
const head = await client.getBranch(link!.owner, link!.repository, branch);
const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
const runs = exactHeadActionRuns(response.workflow_runs, branch, head.commit.id);
let successful = true;
try { assertSuccessfulActionRuns(runs, branch, head.commit.id); } catch { successful = false; }
return asResult({ branch, headSha: head.commit.id, runs, successful }, "Feature validation runs");
}));
server.registerTool("sync_feature_validation", {
title: "Verify feature with Gitea Actions", description: "Verify and persist a successful Gitea Actions run for the exact current feature branch head. This authorizes opening a pull request and refreshes evidence after an open pull request branch changes.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "sync_feature_validation", projectId: project_id, deliveryId: delivery_id }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
if (!delivery.spec.branch || delivery.approvalState !== "approved" || !canSyncFeatureDeliveryValidation(delivery.spec.status)) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN");
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
const head = await client.getBranch(link!.owner, link!.repository, branch);
const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
const verifiedRuns = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id);
const run = verifiedRuns.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest);
const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() };
return asResult({ run, delivery: await recordFeatureDeliveryActionValidation(project_id, delivery_id, evidence, actor) }, "Gitea Actions validation verified and persisted");
}, true));
server.registerTool("complete_feature_delivery", {
title: "Open feature pull request", description: "Use this after an approved feature delivery is implemented and its validation results are recorded. Opens the pull request and links it back to the originating feature plan in ProjectBrain.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), title: z.string().trim().min(3).max(200), body: z.string().max(20_000).default("") },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id, title, body }) => withAudit({ actor, tool: "complete_feature_delivery", projectId: project_id, deliveryId: delivery_id }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
if (!delivery.spec.branch || delivery.approvalState !== "approved") throw new Error("FEATURE_DELIVERY_NOT_STARTED");
if (delivery.spec.pullRequest) return asResult(delivery, "Feature delivery already has a pull request");
if (delivery.spec.validationResults.some((result) => result.status === "failed")) throw new Error("FEATURE_DELIVERY_VALIDATION_FAILED");
// Opening review needs verified exact-head Actions evidence below. Planned
// PR checks cannot have run before the PR exists, and a dispatch must never
// manufacture passed results for those commands. Merge still uses Gitea's
// separate protected-check gate; reported failures continue to block review.
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
const head = await client.getBranch(link!.owner, link!.repository, branch);
assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, head.commit.id);
const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, head.commit.id);
const existing = findReusableDeliveryPullRequest(await client.listPullRequests(link!.owner, link!.repository, "all"), branch, link!.defaultBranch, head.commit.id);
const pullRequest = existing ?? await client.createPullRequest(link!.owner, link!.repository, { title, body, head: branch, base: link!.defaultBranch });
let updated = await openFeatureDeliveryPullRequest(project_id, delivery_id, { number: pullRequest.number, url: pullRequest.html_url, title: pullRequest.title }, actor);
if (pullRequest.merged) updated = await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: pullRequest.number, state: "closed", merged: true, mergeCommitSha: pullRequest.merge_commit_sha, mergedAt: pullRequest.merged_at, headSha: pullRequest.head.sha, actor }) ?? updated;
return asResult({ repository: link!.fullName, pullRequest, delivery: updated }, pullRequest.merged ? "Existing merged pull request reconciled with delivery" : "Feature pull request created; delivery is awaiting merge");
}, true));
server.registerTool("merge_feature_delivery", {
title: "Merge validated feature delivery",
description: "Merge an open ProjectBrain delivery pull request after exact-head and Gitea Actions verification, reconcile the durable delivery, queue finding verification, and optionally remove the merged work branch. This never writes directly to the default branch outside the reviewed pull request.",
inputSchema: {
project_id: z.string().min(1), delivery_id: z.string().min(1), expected_head_sha: z.string().regex(/^[0-9a-f]{40,64}$/i),
method: z.enum(["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"]).default("squash"),
delete_branch: z.boolean().default(true),
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
_meta: { "openai/toolInvocation/invoking": "Gevalideerde delivery mergen…", "openai/toolInvocation/invoked": "Delivery gemerged" },
}, async ({ project_id, delivery_id, expected_head_sha, method, delete_branch }) => withAudit({ actor, tool: "merge_feature_delivery", projectId: project_id, deliveryId: delivery_id }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const pullRequestNumber = delivery.spec.pullRequest?.number;
if (!pullRequestNumber || !delivery.spec.branch || delivery.approvalState !== "approved") throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN");
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
const pullRequest = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber);
if (pullRequest.head.ref !== branch || pullRequest.base.ref !== link!.defaultBranch || pullRequest.head.sha !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
const cleanupBranch = () => cleanupDeliveryBranch(client, link!, branch, expected_head_sha, delete_branch);
if (pullRequest.merged) {
if (!["merged", "verified", "released"].includes(delivery.spec.status)) await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: pullRequest.number, state: "closed", merged: true, mergedAt: pullRequest.merged_at, mergeCommitSha: pullRequest.merge_commit_sha, headSha: pullRequest.head.sha, actor });
await queueFindingVerificationForDelivery(env, project_id, delivery_id).catch(() => undefined);
const cleanup = await cleanupBranch();
return asCompactResult({ repository: link!.fullName, deliveryId: delivery.id, pullRequest: { number: pullRequest.number, url: pullRequest.html_url, mergeCommitSha: pullRequest.merge_commit_sha }, branch, alreadyMerged: true, ...cleanup }, `PR #${pullRequest.number} was al gemerged.${cleanup.branchDeleted ? " De werkbranch is opgeruimd." : ""}`);
}
if (delivery.spec.status !== "pull_request_open") throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN");
if (pullRequest.merged || pullRequest.state !== "open" || pullRequest.draft === true || pullRequest.mergeable === false) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_READY");
const head = await client.getBranch(link!.owner, link!.repository, branch);
if (head.commit.id !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, expected_head_sha);
const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: expected_head_sha, limit: 30 });
assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, expected_head_sha);
await client.waitForRequiredPullRequestChecks(link!.owner, link!.repository, link!.defaultBranch, expected_head_sha);
const mergeReadyPullRequest = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber);
const mergeReadyHead = await client.getBranch(link!.owner, link!.repository, branch);
if (mergeReadyPullRequest.state !== "open" || mergeReadyPullRequest.merged || mergeReadyPullRequest.head.sha !== expected_head_sha || mergeReadyPullRequest.base.ref !== link!.defaultBranch || mergeReadyHead.commit.id !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
await client.mergePullRequest(link!.owner, link!.repository, pullRequestNumber, { method, headCommitId: expected_head_sha });
const merged = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber);
if (!merged.merged) throw new PartialExternalMutationError("FEATURE_DELIVERY_MERGE_UNCONFIRMED");
const reconciled = await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: merged.number, state: "closed", merged: true, mergedAt: merged.merged_at, mergeCommitSha: merged.merge_commit_sha, headSha: merged.head.sha, actor });
const verification = await queueFindingVerificationForDelivery(env, project_id, delivery_id).catch(() => undefined);
const { branchDeleted, branchCleanupError } = await cleanupBranch();
return asCompactResult({ repository: link!.fullName, deliveryId: reconciled?.id ?? delivery_id, pullRequest: { number: merged.number, url: merged.html_url, mergeCommitSha: merged.merge_commit_sha }, mergeMethod: method, branch, branchDeleted, ...(branchCleanupError ? { branchCleanupError } : {}), verificationQueued: Boolean(verification) }, `PR #${merged.number} is via ${method} gemerged.${branchDeleted ? " De werkbranch is opgeruimd." : branchCleanupError ? " De merge slaagde; branchopruiming kan afzonderlijk worden herhaald." : ""}`);
}, true));
return server;
};
+72
View File
@@ -0,0 +1,72 @@
import { createHash } from "node:crypto";
const EXTENSION_ID = "io.modelcontextprotocol/skills";
const SERVER_SLUG = "projectbrain";
type SkillDefinition = { name: string; description: string; body: string };
const definitions: SkillDefinition[] = [
{
name: "projectbrain-operator",
description: "Operate Auto Mode with ProjectBrain as controller and Gitea as the canonical repository execution and evidence surface.",
body: `# ProjectBrain operator\n\n1. Start with projectbrain.project.list and projectbrain.context.get when the ProjectBrain connector is available. Durable ProjectBrain state and repository planning files, not chat history, are the source of truth.\n2. ProjectBrain is the controller: it owns Auto policy, roadmap state, dependencies, recovery and human-attention decisions. Gitea is the only primary repository execution surface. ChatGPT may orchestrate and reason, but code changes must travel through governed Gitea branches, validation, pull requests and merge evidence. No OpenAI API key is required for this operating model.\n3. For implementation work, use the canonical branch returned as work.execution.branch. When working directly from repository planning truth without a ProjectBrain claim call, derive the same branch as projectbrain/auto/<normalized-work-id>: lowercase the durable task id and replace non alphanumeric, dot, underscore or hyphen runs with hyphens. The existence of that branch is the atomic execution claim. If it already exists, resume it; never create a competing branch for the same task.\n4. Load repository context from the current default branch, then resume or create the canonical non-default work branch. Use the Gitea workspace changeset flow for edits, run the narrowest managed validation profile and a full profile before merge when required, open or reuse the PR, confirm exact-head validation evidence, review the diff, and merge the exact validated head. Direct default-branch writes remain forbidden.\n5. An open PR is unfinished. A work item is implementation-complete only when its canonical PR is merged and the exact PR head has successful required Gitea Actions evidence. ProjectBrain then reconciles that evidence and downstream state back into Auto Mode.\n6. heartbeat_auto_executor, claim_auto_work and report_auto_work remain backward-compatible controller coordination tools. They are not the repository correctness authority. A missing or interrupted report_auto_work callback must never invalidate a valid Gitea branch/PR/validation/merge chain. When a compatibility lease is present, reuse it; after Gitea reconciliation a late report is an idempotent replay.\n7. For brainstorm or phase-review work, inspect ProjectBrain context plus repository roadmap/backlog, findings and evidence. Persist actionable implementation work into repository planning truth or ProjectBrain durable tasks, then execute resulting code work through the same canonical Gitea path. Do not invent routine approvals.\n8. Treat transient runner, Gitea, network and provider failures as retryable. Escalate to human attention only for credentials, policy authorization, destructive risk or a genuine product decision. Local models such as Ollama are optional helpers when explicitly permitted; they do not change the Gitea execution contract.\n`,
},
{
name: "repository-bootstrap",
description: "Create a branch-ready Gitea repository, onboard it into ProjectBrain immediately, and verify its governed workspace without manual catalog changes.",
body: `# Repository bootstrap\n\n1. Call gitea.repository.create with an explicit name, description, private value, autoInit=true and defaultBranch=main. Reuse the returned repositoryRef; never guess one.\n2. Immediately call projectbrain.portfolio.repository.reconcile for only the returned owner/repository with apply=true. Production also auto-onboards future repositories every fifteen minutes, so a retry is safe and idempotent.\n3. Verify the link with projectbrain.portfolio.repository.list or projectbrain.repository.list and load projectbrain.context.get before planning work.\n4. Inspect gitea.branchprotection.get and use the preview/ensure pair when governance is missing.\n5. Continue changes only on an isolated non-default branch and report the repository URL, repositoryRef, ProjectBrain project and write mode.\n`,
},
{
name: "feature-delivery",
description: "Plan, implement, validate, review, and merge repository work through ProjectBrain's governed feature-delivery loop.",
body: `# Feature delivery\n\n1. Load projectbrain.delivery.get when resuming; durable state, not chat history, is the source of truth.\n2. Create a complete plan. ProjectBrain auto-authorizes owner-controlled MCP delivery plans, so continue directly to start or resume the valid isolated non-default delivery branch instead of asking for routine plan approval.\n3. Apply up to 25 create/update/delete operations atomically with projectbrain.delivery.changeset.apply. Refresh the branch head and file SHAs before every changeset and retry only after re-reading a conflict.\n4. Resume through gitea.workspace.status. Dispatch the narrowest fixed validation profile with gitea.workspace.run and use full before merge when supported. Respect pollAfterMs and poll only while terminal=false; after a transient runner failure, dispatch the same managed profile again instead of using the Gitea UI. Never stream logs into the browser unless diagnosing one selected failed job.\n5. Open or update the pull request, synchronize exact-head validation evidence, and inspect gitea.change.review.\n6. When completion/merge is already part of the user's instruction or an active Auto Mode roadmap, call projectbrain.delivery.merge with the exact head SHA after validation. It is idempotent across retries, rechecks Actions evidence, reconciles ProjectBrain and can clean the merged branch.\n7. Import a compact final execution summary as durable feedback and report changed paths, validation evidence, PR URL and merge state.\n`,
},
{
name: "portfolio-curator",
description: "Work consistently across the complete ProjectBrain/Gitea portfolio while keeping repository-specific context isolated.",
body: `# Portfolio curator\n\n1. Start with projectbrain.portfolio.get. Reconcile newly discovered repositories immediately when the user wants them managed; automatic onboarding is the fallback.\n2. Run broad inspection as a persistent projectbrain.analysis.start job. Respect status.pollAfterMs and poll only while terminal=false, then page projectbrain.analysis.results instead of loading repository trees or logs into the browser.\n3. Use search and fetch only for focused cross-project discovery, then load the selected project's context.\n4. Keep findings attributed to their project, repository, ref, path and SHA. Prefer bounded batches and summarize partial failures instead of silently dropping them.\n5. Sync actionable findings to Gitea issues, investigate them, and create auto-authorized owner deliveries through the focused closed loop. Store reusable conclusions in the correct ProjectBrain project for review.\n`,
},
];
const resources = new Map<string, { uri: string; mimeType: string; text: string; digest: string }>();
const skills = definitions.map(({ name, description, body }) => {
const frontmatter = { name, description };
const text = `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}`;
const uri = `skill://${SERVER_SLUG}/${name}/SKILL.md`;
const digest = `sha256:${createHash("sha256").update(text).digest("hex")}`;
resources.set(uri, { uri, mimeType: "text/markdown", text, digest });
return { uri, frontmatter, resources: [{ uri, digest }] };
});
export const projectBrainSkills = skills;
export const handleProjectBrainSkillMethod = (method: string, params: Record<string, unknown>) => {
if (method === "skills/list") {
if (params.cursor !== undefined && params.cursor !== null) throw new Error("MCP_SKILLS_CURSOR_INVALID");
return { skills };
}
if (method === "skills/get") {
const skill = skills.find((item) => item.uri === params.uri);
if (!skill) throw new Error("MCP_SKILL_NOT_FOUND");
return { skill };
}
if (method === "resources/read" && typeof params.uri === "string" && params.uri.startsWith("skill://")) {
const resource = resources.get(params.uri);
if (!resource) throw new Error("MCP_SKILL_RESOURCE_NOT_FOUND");
return { contents: [{ uri: resource.uri, mimeType: resource.mimeType, text: resource.text }] };
}
return undefined;
};
export const augmentProjectBrainInitialize = (payload: unknown) => {
if (!payload || typeof payload !== "object") return payload;
const document = payload as { result?: { capabilities?: Record<string, unknown>; instructions?: string } };
if (!document.result || typeof document.result !== "object") return payload;
const capabilities = document.result.capabilities ?? {};
const extensions = capabilities.extensions && typeof capabilities.extensions === "object" ? capabilities.extensions as Record<string, unknown> : {};
extensions[EXTENSION_ID] ??= {};
document.result.capabilities = { ...capabilities, extensions };
const autoInstruction = "ProjectBrain Auto Mode uses Gitea as its only primary repository execution surface. ProjectBrain remains the durable controller for roadmap, dependencies, recovery and human decisions; ChatGPT may orchestrate reasoning, but implementation changes must use governed Gitea branches, exact-head validation, pull requests and merge. For an Auto implementation item, use work.execution.branch when available or the deterministic projectbrain/auto/<normalized-work-id> branch when working directly from repository planning truth. Branch existence is the atomic claim: resume an existing branch instead of creating a duplicate. Completion is reconciled from the canonical PR, successful exact-head Gitea Actions evidence, its merge commit and downstream ProjectBrain state. heartbeat_auto_executor, claim_auto_work and report_auto_work are backward-compatible coordination/bookkeeping surfaces only; missing report_auto_work must never make a valid Gitea execution appear incomplete. Never write directly to the default branch, bypass validation, or create parallel branches for one durable work id.";
document.result.instructions = document.result.instructions ? `${document.result.instructions}\n\n${autoInstruction}` : autoInstruction;
return document;
};
+15
View File
@@ -0,0 +1,15 @@
// The resource URI is the host-side cache key. Bump it whenever the widget
// document or its MCP Apps contract changes so ChatGPT cannot reuse stale HTML.
export const PROJECTBRAIN_WORKSPACE_URI = "ui://projectbrain/workspace-v2.html";
export const PROJECTBRAIN_WORKSPACE_MIME_TYPE = "text/html;profile=mcp-app";
export const PROJECTBRAIN_WORKSPACE_HTML = String.raw`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ProjectBrain workspace</title><style>
:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;--fg:#202124}:root[data-theme="dark"]{--fg:#edf1f7;color-scheme:dark}:root[data-theme="light"]{--fg:#202124;color-scheme:light}*{box-sizing:border-box}body{margin:0;padding:14px;background:transparent;color:var(--fg)}main{display:grid;gap:12px}.top{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}h1{font-size:17px;margin:0 0 4px}.muted{opacity:.66;font-size:12px}.pill{border-radius:999px;padding:5px 9px;font-size:12px;font-weight:650;background:#daf3df;color:#145c26}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(145px,1fr));gap:9px}.card{border:1px solid color-mix(in srgb,currentColor 18%,transparent);border-radius:10px;padding:10px;min-width:0}.label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;opacity:.62}.value{font-size:13px;font-weight:600;margin-top:4px;overflow-wrap:anywhere}ul{list-style:none;margin:6px 0 0;padding:0;display:grid;gap:5px}li{display:flex;justify-content:space-between;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid color-mix(in srgb,currentColor 10%,transparent)}li:last-child{border:0}button{font:inherit;border:1px solid color-mix(in srgb,currentColor 25%,transparent);background:transparent;color:inherit;border-radius:8px;padding:7px 10px;cursor:pointer}button:disabled{opacity:.45}.actions{display:flex;gap:7px;flex-wrap:wrap}.error{color:#a12626;font-size:12px}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--fg:#edf1f7}.pill{background:#183f23;color:#b9f1c5}}
</style></head><body><main id="root" aria-live="polite"><div class="muted">ProjectBrain laden…</div></main><script>
(()=>{const root=document.getElementById('root');let state=null,nextId=1;const pending=new Map();const nl=(document.documentElement.lang||navigator.language||'nl').toLowerCase().startsWith('nl');const t=(en,nlText)=>nl?nlText:en;const node=(tag,value='',cls='')=>{const e=document.createElement(tag);e.textContent=value??'';if(cls)e.className=cls;return e};const card=(label,value)=>{const e=node('div','','card');e.append(node('div',label,'label'),node('div',value??'—','value'));return e};const unwrap=v=>v?.structuredContent?.data??v?.structuredContent??v?.data??v?.toolOutput?.structuredContent?.data??v?.toolOutput?.data??v;
function request(method,params){const id=nextId++;window.parent.postMessage({jsonrpc:'2.0',id,method,params},'*');return new Promise((resolve,reject)=>{const timeout=setTimeout(()=>{pending.delete(id);reject(new Error('MCP Apps bridge timeout'))},5000);pending.set(id,{resolve,reject,timeout})})}function theme(value){if(value==='dark'||value==='light')document.documentElement.dataset.theme=value}
function render(raw){const data=unwrap(raw);if(!data||typeof data!=='object'){root.replaceChildren(node('div',t('No workspace data returned.','Geen werkruimtegegevens ontvangen.'),'muted'));return}state=data;const project=data.project||{};const repo=data.repository||{};const deliveries=Array.isArray(data.featureDeliveries)?data.featureDeliveries:[];const reviews=Array.isArray(data.pendingReviews)?data.pendingReviews:[];const heading=node('div');heading.append(node('h1',project.name||project.title||project.slug||data.projectId||'ProjectBrain'),node('div',repo.fullName||t('Project memory and delivery','Projectgeheugen en uitvoering'),'muted'));const top=node('section','','top');top.append(heading,node('span',t('Ready','Gereed'),'pill'));const grid=node('section','','grid');grid.append(card(t('Deliveries','Uitvoeringen'),String(deliveries.length)),card(t('Pending review','Te beoordelen'),String(reviews.length)),card(t('Context packs','Contextpakketten'),String(data.contextPackCount??0)),card(t('Repository','Repository'),repo.defaultBranch||'—'));const detail=node('section','','card');detail.append(node('div',t('Active delivery work','Actieve uitvoering'),'label'));const list=node('ul');deliveries.slice(0,12).forEach(item=>{const row=node('li');row.append(node('span',item.title||item.id),node('span',item.spec?.status||item.status||'','muted'));list.append(row)});detail.append(deliveries.length?list:node('div',t('No feature delivery is active.','Geen feature-uitvoering actief.'),'muted'));const actions=node('section','','actions');const refresh=node('button',t('Refresh context','Context vernieuwen'));refresh.onclick=async()=>{refresh.disabled=true;const args={project_id:data.projectId||project.slug};try{let result;try{result=await request('tools/call',{name:'projectbrain.workspace.render',arguments:args})}catch(first){try{result=await request('tools/call',{name:'render_project_workspace',arguments:args})}catch(second){if(!window.openai?.callTool)throw second;try{result=await window.openai.callTool('projectbrain.workspace.render',args)}catch{result=await window.openai.callTool('render_project_workspace',args)}}}render(result)}catch(e){root.append(node('div',String(e?.message||e),'error'))}finally{refresh.disabled=false}};actions.append(refresh);root.replaceChildren(top,grid,detail,actions);window.openai?.setWidgetState?.({projectId:data.projectId||project.slug||null,deliveryCount:deliveries.length})}
window.addEventListener('message',event=>{if(event.source!==window.parent)return;const message=event.data;if(!message||message.jsonrpc!=='2.0')return;if(message.id!==undefined&&pending.has(message.id)){const item=pending.get(message.id);pending.delete(message.id);clearTimeout(item.timeout);message.error?item.reject(message.error):item.resolve(message.result);return}if(message.method==='ui/notifications/tool-result')render(message.params?.structuredContent)},{passive:true});window.addEventListener('openai:set_globals',event=>{theme(event.detail?.globals?.theme);render(event.detail?.globals?.toolOutput)});theme(window.openai?.theme);render(window.openai?.toolOutput)})();
</script></body></html>`;