@productbrain/mcp 0.0.1-beta.2 → 0.0.1-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4546 @@
1
+ import {
2
+ closeAgentSession,
3
+ getAgentSessionId,
4
+ getAuditLog,
5
+ getWorkspaceContext,
6
+ getWorkspaceId,
7
+ isSessionOriented,
8
+ mcpCall,
9
+ mcpMutation,
10
+ mcpQuery,
11
+ recordSessionActivity,
12
+ registerSmartCaptureTools,
13
+ requireWriteAccess,
14
+ setSessionOriented,
15
+ startAgentSession
16
+ } from "./chunk-CXYNWTRQ.js";
17
+
18
+ // src/server.ts
19
+ import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
20
+
21
+ // src/tools/knowledge.ts
22
+ import { z } from "zod";
23
+ function extractPreview(data, maxLen) {
24
+ if (!data || typeof data !== "object") return "";
25
+ const raw = data.description ?? data.canonical ?? data.detail ?? "";
26
+ if (typeof raw !== "string" || !raw) return "";
27
+ return raw.length > maxLen ? raw.substring(0, maxLen) + "..." : raw;
28
+ }
29
+ function registerKnowledgeTools(server) {
30
+ server.registerTool(
31
+ "list-collections",
32
+ {
33
+ title: "Browse Collections",
34
+ description: "List every knowledge collection in the workspace \u2014 glossary, business rules, tracking events, standards, etc. Returns each collection's slug, name, description, and field schema. Start here before capture so you know which collections exist and what fields they expect.",
35
+ annotations: { readOnlyHint: true }
36
+ },
37
+ async () => {
38
+ const collections = await mcpQuery("chain.listCollections");
39
+ if (collections.length === 0) {
40
+ return { content: [{ type: "text", text: "No collections found in this workspace." }] };
41
+ }
42
+ const formatted = collections.map((c) => {
43
+ const fieldList = c.fields.map((f) => ` - \`${f.key}\` (${f.type}${f.required ? ", required" : ""}${f.searchable ? ", searchable" : ""})`).join("\n");
44
+ const meta = [
45
+ c.description || "_No description_",
46
+ c.purpose ? `**Purpose:** ${c.purpose}` : null,
47
+ c.navGroup ? `**Nav group:** ${c.navGroup}` : null
48
+ ].filter(Boolean).join("\n");
49
+ return `## ${c.name} (\`${c.slug}\`)
50
+ ${meta}
51
+
52
+ **Fields:**
53
+ ${fieldList}`;
54
+ }).join("\n\n---\n\n");
55
+ return {
56
+ content: [{ type: "text", text: `# Knowledge Collections (${collections.length})
57
+
58
+ ${formatted}` }]
59
+ };
60
+ }
61
+ );
62
+ server.registerTool(
63
+ "list-entries",
64
+ {
65
+ title: "Browse Entries",
66
+ description: "List entries in a collection, with optional filters for status, tag, or label. Returns entry IDs, names, status, and a data preview. Use list-collections first to discover available collection slugs.",
67
+ inputSchema: {
68
+ collection: z.string().optional().describe("Collection slug, e.g. 'glossary', 'tracking-events', 'business-rules'"),
69
+ status: z.string().optional().describe("Filter: draft | active | verified | deprecated"),
70
+ tag: z.string().optional().describe("Filter by internal tag, e.g. 'health:ambiguous'"),
71
+ label: z.string().optional().describe("Filter by label slug \u2014 matches entries across all collections")
72
+ },
73
+ annotations: { readOnlyHint: true }
74
+ },
75
+ async ({ collection, status, tag, label }) => {
76
+ let entries;
77
+ if (label) {
78
+ entries = await mcpQuery("chain.listEntriesByLabel", { labelSlug: label });
79
+ if (status) entries = entries.filter((e) => e.status === status);
80
+ } else {
81
+ entries = await mcpQuery("chain.listEntries", {
82
+ collectionSlug: collection,
83
+ status,
84
+ tag
85
+ });
86
+ }
87
+ if (entries.length === 0) {
88
+ return { content: [{ type: "text", text: "No entries match the given filters." }] };
89
+ }
90
+ const formatted = entries.map((e) => {
91
+ const id = e.entryId ? `**${e.entryId}:** ` : "";
92
+ const dataPreview = e.data ? Object.entries(e.data).slice(0, 4).map(([k, v]) => ` ${k}: ${typeof v === "string" ? v.substring(0, 120) : JSON.stringify(v)}`).join("\n") : "";
93
+ return `- ${id}${e.name} \`${e.status}\`${dataPreview ? `
94
+ ${dataPreview}` : ""}`;
95
+ }).join("\n\n");
96
+ const scope = collection ? ` in \`${collection}\`` : "";
97
+ return {
98
+ content: [{ type: "text", text: `# Entries${scope} (${entries.length})
99
+
100
+ ${formatted}` }]
101
+ };
102
+ }
103
+ );
104
+ server.registerTool(
105
+ "get-entry",
106
+ {
107
+ title: "Look Up Entry",
108
+ description: "Retrieve a single knowledge entry by its human-readable ID (e.g. 'T-SUPPLIER', 'BR-001', 'EVT-workspace_created'). Returns the full record: all data fields, labels, relations, and change history. Use search or list-entries first to discover entry IDs.",
109
+ inputSchema: {
110
+ entryId: z.string().describe("Entry ID, e.g. 'T-SUPPLIER', 'BR-001', 'EVT-workspace_created'")
111
+ },
112
+ annotations: { readOnlyHint: true }
113
+ },
114
+ async ({ entryId }) => {
115
+ const entry = await mcpQuery("chain.getEntry", { entryId });
116
+ if (!entry) {
117
+ return { content: [{ type: "text", text: `Entry \`${entryId}\` not found. Try search to find the right ID.` }] };
118
+ }
119
+ const lines = [
120
+ `# ${entry.entryId ? `${entry.entryId}: ` : ""}${entry.name}`,
121
+ "",
122
+ `**Status:** ${entry.status}`,
123
+ `**Type:** ${entry.canonicalKey ?? "untyped"}`
124
+ ];
125
+ if (entry.data && typeof entry.data === "object") {
126
+ lines.push("");
127
+ for (const [key, val] of Object.entries(entry.data)) {
128
+ const display = typeof val === "string" ? val : JSON.stringify(val);
129
+ lines.push(`**${key}:** ${display}`);
130
+ }
131
+ }
132
+ if (entry.tags?.length > 0) {
133
+ lines.push("", `**Tags:** ${entry.tags.join(", ")}`);
134
+ }
135
+ if (entry.labels?.length > 0) {
136
+ lines.push("", `**Labels:** ${entry.labels.map((l) => `\`${l.slug ?? l.name}\``).join(", ")}`);
137
+ }
138
+ if (entry.relations?.length > 0) {
139
+ lines.push("", "## Relations");
140
+ for (const r of entry.relations) {
141
+ const arrow = r.direction === "outgoing" ? "\u2192" : "\u2190";
142
+ const other = r.otherEntryId ? `${r.otherEntryId}: ${r.otherName}` : r.otherName ?? "unknown";
143
+ lines.push(`- ${arrow} **${r.type}** ${other}`);
144
+ }
145
+ }
146
+ if (entry.history?.length > 0) {
147
+ lines.push("", "## History (last 10)");
148
+ for (const h of entry.history.slice(-10)) {
149
+ const date = new Date(h.timestamp).toISOString().split("T")[0];
150
+ lines.push(`- ${date}: ${h.event}${h.changedBy ? ` _(${h.changedBy})_` : ""}`);
151
+ }
152
+ }
153
+ return { content: [{ type: "text", text: lines.join("\n") }] };
154
+ }
155
+ );
156
+ server.registerTool(
157
+ "update-entry",
158
+ {
159
+ title: "Update Entry",
160
+ description: "Update an existing entry by its human-readable ID. Only provide the fields you want to change \u2014 data fields are merged with existing values. Creates a draft version by default. **Never use autoPublish=true unless the user explicitly asks to publish** \u2014 same rule as commit-entry. Use get-entry first to see current values.",
161
+ inputSchema: {
162
+ entryId: z.string().describe("Entry ID to update, e.g. 'T-SUPPLIER', 'BR-001'"),
163
+ name: z.string().optional().describe("New display name"),
164
+ status: z.string().optional().describe("New status: draft | active | verified | deprecated"),
165
+ data: z.record(z.unknown()).optional().describe("Fields to update (merged with existing data)"),
166
+ order: z.number().optional().describe("New sort order"),
167
+ canonicalKey: z.string().optional().describe("Semantic type (e.g. 'decision', 'tension'). Only changeable on draft/uncommitted entries."),
168
+ autoPublish: z.boolean().optional().default(false).describe("Only true when user explicitly asks to publish. Default false = draft. Never auto-publish without user confirmation."),
169
+ changeNote: z.string().optional().describe("Short human-readable summary for history (e.g. 'Updated description to F1-themed copy'). If omitted, a friendly default is generated from fields changed.")
170
+ },
171
+ annotations: { idempotentHint: true, destructiveHint: false }
172
+ },
173
+ async ({ entryId, name, status, data, order, canonicalKey, autoPublish, changeNote }) => {
174
+ requireWriteAccess();
175
+ const id = await mcpMutation("chain.updateEntry", {
176
+ entryId,
177
+ name,
178
+ status,
179
+ data,
180
+ order,
181
+ canonicalKey,
182
+ autoPublish,
183
+ changeNote,
184
+ changedBy: getAgentSessionId() ? `agent:${getAgentSessionId()}` : void 0
185
+ });
186
+ await recordSessionActivity({ entryModified: id });
187
+ const wsCtx = await getWorkspaceContext();
188
+ const mode = autoPublish ? "published" : "saved as draft";
189
+ return {
190
+ content: [{ type: "text", text: `# Entry Updated
191
+
192
+ **${entryId}** has been ${mode}.
193
+
194
+ Internal ID: ${id}
195
+ **Workspace:** ${wsCtx.workspaceSlug} (${wsCtx.workspaceId})` }]
196
+ };
197
+ }
198
+ );
199
+ server.registerTool(
200
+ "search",
201
+ {
202
+ title: "Search the Chain",
203
+ description: "Full-text search across all entries on the Chain. Returns entry names, collection, status, and a description preview. Scope results to a specific collection (e.g. collection='business-rules') or filter by status (e.g. status='active'). Use this to discover entries before calling get-entry for full details.",
204
+ inputSchema: {
205
+ query: z.string().describe("Search text (min 2 characters)"),
206
+ collection: z.string().optional().describe("Scope to a collection slug, e.g. 'business-rules', 'glossary', 'tracking-events'"),
207
+ status: z.string().optional().describe("Filter by status: draft | active | verified | deprecated")
208
+ },
209
+ annotations: { readOnlyHint: true, openWorldHint: true }
210
+ },
211
+ async ({ query, collection, status }) => {
212
+ const scope = collection ? ` in \`${collection}\`` : "";
213
+ await server.sendLoggingMessage({ level: "info", data: `Searching${scope} for "${query}"...`, logger: "product-os" });
214
+ const [results, collections] = await Promise.all([
215
+ mcpQuery("chain.searchEntries", { query, collectionSlug: collection, status }),
216
+ mcpQuery("chain.listCollections")
217
+ ]);
218
+ if (results.length === 0) {
219
+ return { content: [{ type: "text", text: `No results for "${query}"${scope}. Try a broader search or check list-collections for available data.` }] };
220
+ }
221
+ const collMap = /* @__PURE__ */ new Map();
222
+ for (const c of collections) {
223
+ collMap.set(c._id, { name: c.name, slug: c.slug });
224
+ }
225
+ const countsBySlug = /* @__PURE__ */ new Map();
226
+ for (const e of results) {
227
+ const col = collMap.get(e.collectionId);
228
+ const slug = col?.slug ?? "unknown";
229
+ countsBySlug.set(slug, (countsBySlug.get(slug) ?? 0) + 1);
230
+ }
231
+ const collSummary = [...countsBySlug.entries()].sort((a, b) => b[1] - a[1]).map(([slug, count]) => `${count} ${slug}`).join(", ");
232
+ const formatted = results.map((e) => {
233
+ const id = e.entryId ? `**${e.entryId}:** ` : "";
234
+ const col = collMap.get(e.collectionId);
235
+ const colTag = col ? ` [${col.slug}]` : "";
236
+ const typeTag = e.canonicalKey ? ` (${e.canonicalKey})` : "";
237
+ const desc = extractPreview(e.data, 150);
238
+ const preview = desc ? `
239
+ ${desc}` : "";
240
+ return `- ${id}${e.name} \`${e.status}\`${colTag}${typeTag}${preview}`;
241
+ }).join("\n");
242
+ const header = `# Search Results for "${query}"${scope} (${results.length} match${results.length === 1 ? "" : "es"})
243
+
244
+ **By collection:** ${collSummary}`;
245
+ const footer = `_Tip: Use \`collection\` param to scope search. Use get-entry with an entry ID for full details._`;
246
+ return {
247
+ content: [{ type: "text", text: `${header}
248
+
249
+ ${formatted}
250
+
251
+ ${footer}` }]
252
+ };
253
+ }
254
+ );
255
+ server.registerTool(
256
+ "get-history",
257
+ {
258
+ title: "Entry Change History",
259
+ description: "Get the audit trail for an entry \u2014 when it was created, updated, status-changed, etc. Returns timestamped events with change details.",
260
+ inputSchema: {
261
+ entryId: z.string().describe("Entry ID, e.g. 'T-SUPPLIER', 'BR-001'")
262
+ },
263
+ annotations: { readOnlyHint: true }
264
+ },
265
+ async ({ entryId }) => {
266
+ const history = await mcpQuery("chain.listEntryHistory", { entryId });
267
+ if (history.length === 0) {
268
+ return { content: [{ type: "text", text: `No history found for \`${entryId}\`.` }] };
269
+ }
270
+ const formatted = history.map((h) => {
271
+ const date = new Date(h.timestamp).toISOString();
272
+ const changes = h.changes ? ` \u2014 ${JSON.stringify(h.changes)}` : "";
273
+ return `- **${date}** ${h.event}${h.changedBy ? ` _(${h.changedBy})_` : ""}${changes}`;
274
+ }).join("\n");
275
+ return {
276
+ content: [{ type: "text", text: `# History for \`${entryId}\` (${history.length} events)
277
+
278
+ ${formatted}` }]
279
+ };
280
+ }
281
+ );
282
+ server.registerTool(
283
+ "relate-entries",
284
+ {
285
+ title: "Link Two Entries",
286
+ description: "Create a typed relation between two entries, building the knowledge graph. Use get-entry to see existing relations before adding new ones.\n\nRecommended relation types (extensible \u2014 any string is accepted):\n- related_to, depends_on, replaces, conflicts_with, references, confused_with\n- governs \u2014 a rule constrains behavior of a feature\n- defines_term_for \u2014 a glossary term is canonical vocabulary for a feature/area\n- belongs_to \u2014 a feature belongs to a product area or parent concept\n- informs \u2014 a decision or insight informs a feature\n- surfaces_tension_in \u2014 a tension exists within a feature area\n\nCheck glossary for relation type definitions if unsure which to use.",
287
+ inputSchema: {
288
+ from: z.string().describe("Source entry ID, e.g. 'T-SUPPLIER'"),
289
+ to: z.string().describe("Target entry ID, e.g. 'BR-001'"),
290
+ type: z.string().describe("Relation type \u2014 use a recommended type or any descriptive string"),
291
+ score: z.number().optional().describe("Suggestion score from suggest-links (improves feedback for weight tuning)")
292
+ },
293
+ annotations: { destructiveHint: false }
294
+ },
295
+ async ({ from, to, type, score }) => {
296
+ requireWriteAccess();
297
+ const result = await mcpMutation("chain.createEntryRelation", {
298
+ fromEntryId: from,
299
+ toEntryId: to,
300
+ type,
301
+ suggestionScore: score ?? void 0
302
+ });
303
+ await recordSessionActivity({ relationCreated: result?.status !== "proposal_created" });
304
+ const wsCtx = await getWorkspaceContext();
305
+ if (result?.status === "proposal_created") {
306
+ const existingNote = result.existing ? " (existing proposal reused)" : "";
307
+ return {
308
+ content: [{
309
+ type: "text",
310
+ text: `# Proposal Created \u2014 Governs Relation
311
+
312
+ **${result.fromEntryId ?? from}** \u2014[${type}]\u2192 **${result.toEntryId ?? to}**
313
+
314
+ ${result.message ?? "Governs relation requires async consent. A proposal was created for review."}
315
+
316
+ **Proposal ID:** \`${result.proposalId}\`${existingNote}
317
+ **Workspace:** ${wsCtx.workspaceSlug} (${wsCtx.workspaceId})
318
+
319
+ The relation was **not applied** \u2014 it will be created when the proposal is approved.`
320
+ }]
321
+ };
322
+ }
323
+ return {
324
+ content: [{ type: "text", text: `# Relation Created
325
+
326
+ **${from}** \u2014[${type}]\u2192 **${to}**
327
+ **Workspace:** ${wsCtx.workspaceSlug} (${wsCtx.workspaceId})` }]
328
+ };
329
+ }
330
+ );
331
+ server.registerTool(
332
+ "batch-relate",
333
+ {
334
+ title: "Batch Link Entries",
335
+ description: "Create multiple relations in one call. Accepts an array of {from, to, type} objects. Use after suggest-links to apply several suggestions at once instead of calling relate-entries repeatedly.\n\nEach relation is created independently \u2014 if one fails, the others still succeed. Returns a summary of created and failed relations.",
336
+ inputSchema: {
337
+ relations: z.array(z.object({
338
+ from: z.string().describe("Source entry ID"),
339
+ to: z.string().describe("Target entry ID"),
340
+ type: z.string().describe("Relation type")
341
+ })).min(1).max(20).describe("Array of relations to create")
342
+ },
343
+ annotations: { destructiveHint: false }
344
+ },
345
+ async ({ relations }) => {
346
+ requireWriteAccess();
347
+ const results = [];
348
+ for (const rel of relations) {
349
+ try {
350
+ const result = await mcpMutation("chain.createEntryRelation", {
351
+ fromEntryId: rel.from,
352
+ toEntryId: rel.to,
353
+ type: rel.type
354
+ });
355
+ results.push({
356
+ ...rel,
357
+ ok: true,
358
+ proposalCreated: result?.status === "proposal_created",
359
+ proposalId: result?.proposalId
360
+ });
361
+ } catch (e) {
362
+ results.push({ ...rel, ok: false, error: e.message || "Unknown error" });
363
+ }
364
+ }
365
+ const created = results.filter((r) => r.ok && !r.proposalCreated);
366
+ const proposals = results.filter((r) => r.ok && r.proposalCreated);
367
+ const failed = results.filter((r) => !r.ok);
368
+ const lines = [`# Batch Link Results
369
+ `];
370
+ lines.push(`**${created.length}** created, **${proposals.length}** proposals, **${failed.length}** failed out of ${relations.length} total.
371
+ `);
372
+ if (created.length > 0) {
373
+ lines.push("## Created");
374
+ for (const r of created) {
375
+ lines.push(`- **${r.from}** \u2014[${r.type}]\u2192 **${r.to}**`);
376
+ }
377
+ }
378
+ if (proposals.length > 0) {
379
+ lines.push("");
380
+ lines.push("## Proposals (governs \u2014 not yet applied)");
381
+ for (const r of proposals) {
382
+ lines.push(`- **${r.from}** \u2014[${r.type}]\u2192 **${r.to}** \u2014 proposal \`${r.proposalId}\``);
383
+ }
384
+ }
385
+ if (failed.length > 0) {
386
+ lines.push("");
387
+ lines.push("## Failed");
388
+ for (const r of failed) {
389
+ lines.push(`- **${r.from}** \u2192 **${r.to}** (${r.type}): _${r.error}_`);
390
+ }
391
+ }
392
+ return { content: [{ type: "text", text: lines.join("\n") }] };
393
+ }
394
+ );
395
+ server.registerTool(
396
+ "find-related",
397
+ {
398
+ title: "Find Related Entries",
399
+ description: "Navigate the knowledge graph \u2014 find all entries related to a given entry. Shows incoming references (what points to this entry), outgoing references (what this entry points to), or both. Use after get-entry to explore connections, or to answer 'what depends on X?' or 'what references Y?'",
400
+ inputSchema: {
401
+ entryId: z.string().describe("Entry ID, e.g. 'GT-019', 'SOS-006'"),
402
+ direction: z.enum(["incoming", "outgoing", "both"]).default("both").describe("Filter: 'incoming' = what references this entry, 'outgoing' = what this entry references")
403
+ },
404
+ annotations: { readOnlyHint: true }
405
+ },
406
+ async ({ entryId, direction }) => {
407
+ const relations = await mcpQuery("chain.listEntryRelations", { entryId });
408
+ if (relations.length === 0) {
409
+ return { content: [{ type: "text", text: `No relations found for \`${entryId}\`. Use relate-entries to create connections.` }] };
410
+ }
411
+ const sourceEntry = await mcpQuery("chain.getEntry", { entryId });
412
+ if (!sourceEntry) {
413
+ return { content: [{ type: "text", text: `Entry \`${entryId}\` not found. Try search to find the right ID.` }] };
414
+ }
415
+ const sourceInternalId = sourceEntry._id;
416
+ const MAX_RELATIONS = 25;
417
+ const truncated = relations.length > MAX_RELATIONS;
418
+ const capped = relations.slice(0, MAX_RELATIONS);
419
+ const otherIds = /* @__PURE__ */ new Set();
420
+ for (const r of capped) {
421
+ const otherId = r.fromId === sourceInternalId ? r.toId : r.fromId;
422
+ otherIds.add(otherId);
423
+ }
424
+ const otherEntries = /* @__PURE__ */ new Map();
425
+ for (const id of otherIds) {
426
+ const entry = await mcpQuery("chain.getEntry", { id });
427
+ if (entry) {
428
+ otherEntries.set(entry._id, { entryId: entry.entryId, name: entry.name, collectionId: entry.collectionId });
429
+ }
430
+ }
431
+ const collections = await mcpQuery("chain.listCollections");
432
+ const collMap = /* @__PURE__ */ new Map();
433
+ for (const c of collections) collMap.set(c._id, c.slug);
434
+ const lines = [`# Relations for ${entryId}: ${sourceEntry.name}`, ""];
435
+ const enriched = capped.map((r) => {
436
+ const isOutgoing = r.fromId === sourceInternalId;
437
+ const otherId = isOutgoing ? r.toId : r.fromId;
438
+ const other = otherEntries.get(otherId);
439
+ const otherLabel = other?.entryId ? `${other.entryId}: ${other.name}` : other?.name ?? "(deleted)";
440
+ const colSlug = other ? collMap.get(other.collectionId) ?? "unknown" : "unknown";
441
+ return { isOutgoing, type: r.type, otherLabel, colSlug };
442
+ });
443
+ const outgoing = enriched.filter((r) => r.isOutgoing);
444
+ const incoming = enriched.filter((r) => !r.isOutgoing);
445
+ if ((direction === "outgoing" || direction === "both") && outgoing.length > 0) {
446
+ lines.push(`## Outgoing (${outgoing.length})`);
447
+ for (const r of outgoing) {
448
+ lines.push(`- \u2192 **${r.type}** ${r.otherLabel} [${r.colSlug}]`);
449
+ }
450
+ lines.push("");
451
+ }
452
+ if ((direction === "incoming" || direction === "both") && incoming.length > 0) {
453
+ lines.push(`## Incoming (${incoming.length})`);
454
+ for (const r of incoming) {
455
+ lines.push(`- \u2190 **${r.type}** ${r.otherLabel} [${r.colSlug}]`);
456
+ }
457
+ lines.push("");
458
+ }
459
+ const shown = direction === "outgoing" ? outgoing : direction === "incoming" ? incoming : enriched;
460
+ if (shown.length === 0) {
461
+ lines.push(`No ${direction} relations found for \`${entryId}\`.`);
462
+ }
463
+ if (truncated) {
464
+ lines.push(`_Showing first ${MAX_RELATIONS} of ${relations.length} relations. Use get-entry for the full picture._`);
465
+ }
466
+ return { content: [{ type: "text", text: lines.join("\n") }] };
467
+ }
468
+ );
469
+ server.registerTool(
470
+ "gather-context",
471
+ {
472
+ title: "Gather Context",
473
+ description: "Assemble knowledge context in one call. Three modes:\n\n1. **By entry** (entryId): Traverse the knowledge graph around a specific entry. Returns all related entries grouped by collection.\n2. **By task** (task): Auto-load relevant domain knowledge for a natural-language task. Searches the chain, traverses the graph, and returns ranked entries with confidence scores.\n3. **Graph mode** (entryId + mode='graph'): Enhanced graph traversal with provenance \u2014 each entry includes the full path that led to it (via what relations, from what starting point).\n\nUse mode 1/3 when you have a specific entry ID. Use mode 2 at the start of a conversation to ground the agent in domain context before writing code or making recommendations.",
474
+ inputSchema: {
475
+ entryId: z.string().optional().describe("Entry ID for graph traversal, e.g. 'FEAT-001', 'GT-019'"),
476
+ task: z.string().optional().describe("Natural-language task description for auto-loading relevant context"),
477
+ mode: z.enum(["search", "graph"]).default("search").optional().describe("'search' (default, backward-compatible) or 'graph' (enhanced with provenance paths)"),
478
+ maxHops: z.number().min(1).max(3).default(2).describe("How many relation hops to traverse (1=direct only, 2=default, 3=wide net)"),
479
+ maxResults: z.number().min(1).max(25).default(10).optional().describe("Max entries to return in task mode (default 10)")
480
+ },
481
+ annotations: { readOnlyHint: true }
482
+ },
483
+ async ({ entryId, task, mode, maxHops, maxResults }) => {
484
+ if (!entryId && !task) {
485
+ return { content: [{ type: "text", text: "Provide either `entryId` (graph traversal) or `task` (auto-load context for a task)." }] };
486
+ }
487
+ if (entryId && mode === "graph") {
488
+ const result2 = await mcpQuery("chain.graphGatherContext", {
489
+ entryId,
490
+ maxHops: maxHops ?? 2
491
+ });
492
+ if (!result2?.root) {
493
+ return { content: [{ type: "text", text: `Entry \`${entryId}\` not found. Try search to find the right ID.` }] };
494
+ }
495
+ if (result2.context.length === 0) {
496
+ return {
497
+ content: [{
498
+ type: "text",
499
+ text: `# Context for ${result2.root.entryId}: ${result2.root.name}
500
+
501
+ _No relations found._ This entry is not yet connected to the knowledge graph.
502
+
503
+ Use \`suggest-links\` to discover potential connections.`
504
+ }]
505
+ };
506
+ }
507
+ const byCollection2 = /* @__PURE__ */ new Map();
508
+ for (const entry of result2.context) {
509
+ const key = entry.collectionName;
510
+ if (!byCollection2.has(key)) byCollection2.set(key, []);
511
+ byCollection2.get(key).push(entry);
512
+ }
513
+ const lines2 = [
514
+ `# Context for ${result2.root.entryId}: ${result2.root.name} (graph mode)`,
515
+ `_${result2.totalFound} related entries across ${byCollection2.size} collections (${result2.hopsTraversed} hops)_`,
516
+ ""
517
+ ];
518
+ for (const [collName, entries] of byCollection2) {
519
+ lines2.push(`## ${collName} (${entries.length})`);
520
+ for (const e of entries) {
521
+ const arrow = e.relationDirection === "outgoing" ? "\u2192" : "\u2190";
522
+ const id = e.entryId ? `${e.entryId}: ` : "";
523
+ const hopLabel = e.hop > 1 ? ` (hop ${e.hop})` : "";
524
+ const scoreLabel = typeof e.score === "number" ? ` ${e.score}` : "";
525
+ const flagsLabel = e.scoreFlags?.length ? ` [${e.scoreFlags.join(", ")}]` : "";
526
+ const provenancePath = e.provenance && e.provenance.length > 1 ? `
527
+ _Path: ${e.provenance.map((p) => `${p.entryId ?? p.name} [${p.relationType}]`).join(" \u2192 ")}_` : "";
528
+ const preview = e.preview ? `
529
+ ${e.preview.substring(0, 120)}` : "";
530
+ lines2.push(`- ${arrow} **${e.relationType}** ${id}${e.name}${hopLabel}${scoreLabel}${flagsLabel}${provenancePath}${preview}`);
531
+ }
532
+ lines2.push("");
533
+ }
534
+ return { content: [{ type: "text", text: lines2.join("\n") }] };
535
+ }
536
+ if (task && !entryId) {
537
+ await server.sendLoggingMessage({
538
+ level: "info",
539
+ data: `Loading context for task: "${task.substring(0, 80)}..."`,
540
+ logger: "product-brain"
541
+ });
542
+ const result2 = await mcpQuery("chain.taskAwareGatherContext", {
543
+ task,
544
+ maxHops: maxHops ?? 2,
545
+ maxResults: maxResults ?? 15
546
+ });
547
+ if (!result2 || result2.totalFound === 0) {
548
+ return {
549
+ content: [{
550
+ type: "text",
551
+ text: `# Context Loaded
552
+
553
+ **Confidence:** None
554
+
555
+ No context found for this task. The chain may not cover this area yet.
556
+
557
+ _Consider capturing domain knowledge discovered during this task via \`capture\`._`
558
+ }]
559
+ };
560
+ }
561
+ const byCollection2 = /* @__PURE__ */ new Map();
562
+ for (const entry of result2.context) {
563
+ const key = entry.collectionName;
564
+ if (!byCollection2.has(key)) byCollection2.set(key, []);
565
+ byCollection2.get(key).push(entry);
566
+ }
567
+ const lines2 = [
568
+ `# Context Loaded`,
569
+ `**Confidence:** ${result2.confidence.charAt(0).toUpperCase() + result2.confidence.slice(1)}`,
570
+ `**Matched:** ${result2.totalFound} entries across ${byCollection2.size} collection${byCollection2.size === 1 ? "" : "s"}`,
571
+ `**Seeds:** ${result2.seeds?.map((s) => s.entryId ?? s.name).join(", ") ?? "none"}`,
572
+ ""
573
+ ];
574
+ for (const [collName, entries] of byCollection2) {
575
+ lines2.push(`### ${collName} (${entries.length})`);
576
+ for (const e of entries) {
577
+ const arrow = e.relationDirection === "outgoing" ? "\u2192" : "\u2190";
578
+ const id = e.entryId ? `**${e.entryId}:** ` : "";
579
+ const scoreLabel = typeof e.score === "number" ? ` ${e.score}` : "";
580
+ const flagsLabel = e.scoreFlags?.length ? ` [${e.scoreFlags.join(", ")}]` : "";
581
+ const hopLabel = e.hop > 0 ? ` _(hop ${e.hop}, ${arrow} ${e.relationType})_` : "";
582
+ lines2.push(`- ${id}${e.name}${scoreLabel}${flagsLabel}${hopLabel}`);
583
+ }
584
+ lines2.push("");
585
+ }
586
+ lines2.push(`_Use \`get-entry\` for full details on any entry._`);
587
+ return { content: [{ type: "text", text: lines2.join("\n") }] };
588
+ }
589
+ const result = await mcpQuery("chain.gatherContext", { entryId, maxHops });
590
+ if (!result?.root) {
591
+ return { content: [{ type: "text", text: `Entry \`${entryId}\` not found. Try search to find the right ID.` }] };
592
+ }
593
+ if (result.related.length === 0) {
594
+ return {
595
+ content: [{
596
+ type: "text",
597
+ text: `# Context for ${result.root.entryId}: ${result.root.name}
598
+
599
+ _No relations found._ This entry is not yet connected to the knowledge graph.
600
+
601
+ Use \`suggest-links\` to discover potential connections, or \`relate-entries\` to link manually.`
602
+ }]
603
+ };
604
+ }
605
+ const byCollection = /* @__PURE__ */ new Map();
606
+ for (const entry of result.related) {
607
+ const key = entry.collectionName;
608
+ if (!byCollection.has(key)) byCollection.set(key, []);
609
+ byCollection.get(key).push(entry);
610
+ }
611
+ const lines = [
612
+ `# Context for ${result.root.entryId}: ${result.root.name}`,
613
+ `_${result.totalRelations} related entries across ${byCollection.size} collections (${result.hopsTraversed} hops traversed)_`,
614
+ ""
615
+ ];
616
+ for (const [collName, entries] of byCollection) {
617
+ lines.push(`## ${collName} (${entries.length})`);
618
+ for (const e of entries) {
619
+ const arrow = e.relationDirection === "outgoing" ? "\u2192" : "\u2190";
620
+ const hopLabel = e.hop > 1 ? ` (hop ${e.hop})` : "";
621
+ const id = e.entryId ? `${e.entryId}: ` : "";
622
+ lines.push(`- ${arrow} **${e.relationType}** ${id}${e.name}${hopLabel}`);
623
+ }
624
+ lines.push("");
625
+ }
626
+ return { content: [{ type: "text", text: lines.join("\n") }] };
627
+ }
628
+ );
629
+ server.registerTool(
630
+ "suggest-links",
631
+ {
632
+ title: "Suggest Links",
633
+ description: "Discover potential connections for an entry using graph-aware intelligence. Accepts an entry ID (e.g. 'FEAT-001') OR an entry name (e.g. 'Chain Intelligence'). Traverses the knowledge graph (2-hop BFS) and scores candidates by graph distance, text similarity, and relation type fit. Also finds text-similar entries not yet in the graph.\n\nReturns ranked suggestions with confidence scores, recommended relation types, and graph-path reasoning explaining WHY each connection matters.\n\nThis is a discovery tool \u2014 review suggestions and use relate-entries to create the ones that make sense.",
634
+ inputSchema: {
635
+ entryId: z.string().describe("Entry ID (e.g. 'FEAT-001') or entry name (e.g. 'Chain Intelligence') to find suggestions for"),
636
+ limit: z.number().min(1).max(20).default(10).describe("Max number of suggestions to return"),
637
+ depth: z.number().min(1).max(3).default(2).describe("Graph traversal depth: 1=direct neighbors only, 2=default, 3=wide net")
638
+ },
639
+ annotations: { readOnlyHint: true }
640
+ },
641
+ async ({ entryId, limit, depth }) => {
642
+ const result = await mcpQuery("chain.graphSuggestLinks", {
643
+ entryId,
644
+ maxHops: depth ?? 2,
645
+ limit: limit ?? 10
646
+ });
647
+ if (!result || !result.suggestions || result.suggestions.length === 0) {
648
+ return { content: [{ type: "text", text: `No suggestions found for \`${entryId}\` \u2014 it may already be well-connected, or no similar entries exist.` }] };
649
+ }
650
+ const resolved = result.resolvedEntry;
651
+ const resolvedLabel = resolved ? `\`${resolved.entryId ?? resolved.name}\` (${resolved.name})` : `\`${entryId}\``;
652
+ const suggestions = result.suggestions;
653
+ const graphCount = suggestions.filter((s) => s.graphDistance > 0).length;
654
+ const textCount = suggestions.filter((s) => s.graphDistance === -1).length;
655
+ const sourceId = resolved?.entryId ?? entryId;
656
+ const lines = [
657
+ `# Link Suggestions for ${resolvedLabel}`,
658
+ `_${suggestions.length} potential connections (${graphCount} via graph, ${textCount} via text similarity)._`,
659
+ ""
660
+ ];
661
+ const top3 = suggestions.slice(0, 3);
662
+ lines.push("## Quick Wins (top 3)");
663
+ const batchArgs = [];
664
+ for (const s of top3) {
665
+ const tid = s.entryId ?? "(no ID)";
666
+ const relType = s.recommendedRelationType || "related_to";
667
+ lines.push(`- **${tid}**: ${s.name} [${s.collectionSlug}] \u2014 ${s.score}/100 \u2192 \`${relType}\``);
668
+ if (s.entryId) batchArgs.push(`{from:"${sourceId}",to:"${tid}",type:"${relType}"}`);
669
+ }
670
+ lines.push("");
671
+ if (batchArgs.length > 0) {
672
+ lines.push(`**Link all 3:** \`batch-relate relations=[${batchArgs.join(",")}]\``);
673
+ lines.push("");
674
+ }
675
+ if (suggestions.length > 3) {
676
+ lines.push("## All Suggestions");
677
+ for (let i = 0; i < suggestions.length; i++) {
678
+ const s = suggestions[i];
679
+ const scoreBar = "\u2588".repeat(Math.round(s.score / 10)) + "\u2591".repeat(10 - Math.round(s.score / 10));
680
+ const hopLabel = s.graphDistance > 0 ? `${s.graphDistance}-hop` : "text";
681
+ const recType = s.recommendedRelationType !== "related_to" ? ` \u2192 \`${s.recommendedRelationType}\`` : "";
682
+ lines.push(`${i + 1}. **${s.entryId ?? "(no ID)"}**: ${s.name} [${s.collectionSlug}] (${hopLabel})`);
683
+ lines.push(` ${scoreBar} ${s.score}/100${recType}`);
684
+ if (s.preview) {
685
+ lines.push(` ${s.preview}`);
686
+ }
687
+ lines.push(` _${s.reasoning}_`);
688
+ }
689
+ }
690
+ lines.push("");
691
+ lines.push(`**To link one:** \`relate-entries from="${sourceId}" to="{target_id}" type="{type}"\``);
692
+ lines.push(`**To dismiss:** \`dismiss-suggestion from="${sourceId}" to="{target_id}" type="{type}"\``);
693
+ return { content: [{ type: "text", text: lines.join("\n") }] };
694
+ }
695
+ );
696
+ server.registerTool(
697
+ "dismiss-suggestion",
698
+ {
699
+ title: "Dismiss Link Suggestion",
700
+ description: "Record that a suggested link is not relevant. This negative feedback improves future suggestion quality through the weekly weight-tuning cron.\n\nUse after reviewing suggest-links output when a suggestion doesn't make sense.",
701
+ inputSchema: {
702
+ from: z.string().describe("Source entry ID"),
703
+ to: z.string().describe("Suggested target entry ID that was not relevant"),
704
+ type: z.string().default("related_to").describe("The recommended relation type that was dismissed"),
705
+ score: z.number().optional().describe("The suggestion score (from suggest-links output)")
706
+ },
707
+ annotations: { destructiveHint: false }
708
+ },
709
+ async ({ from, to, type, score }) => {
710
+ requireWriteAccess();
711
+ const fromEntry = await mcpQuery("chain.getEntry", { entryId: from });
712
+ if (!fromEntry) {
713
+ return { content: [{ type: "text", text: `Entry \`${from}\` not found.` }] };
714
+ }
715
+ const toEntry = await mcpQuery("chain.getEntry", { entryId: to });
716
+ if (!toEntry) {
717
+ return { content: [{ type: "text", text: `Entry \`${to}\` not found.` }] };
718
+ }
719
+ await mcpMutation("chain.dismissSuggestion", {
720
+ fromEntryId: fromEntry._id,
721
+ suggestedEntryId: toEntry._id,
722
+ recommendedType: type ?? "related_to",
723
+ score: score ?? 0
724
+ });
725
+ return {
726
+ content: [{
727
+ type: "text",
728
+ text: `Dismissed suggestion: ${from} \u2192 ${to} (${type}). Feedback recorded for scoring improvements.`
729
+ }]
730
+ };
731
+ }
732
+ );
733
+ server.registerTool(
734
+ "create-collection",
735
+ {
736
+ title: "Create Collection",
737
+ description: "Create a new knowledge collection in the workspace. Collections define the structure for entries (like Notion databases or Capacity boards). Provide a slug, name, and field schema.\n\nUse this when setting up a workspace or when the user wants to track a new type of knowledge. Use `list-collections` first to see what already exists.\n\n**Tip:** Provide `purpose` and `navGroup` to improve sidebar placement and AI retrieval quality.",
738
+ inputSchema: {
739
+ slug: z.string().describe("URL-safe identifier, e.g. 'glossary', 'tech-debt', 'api-endpoints'"),
740
+ name: z.string().describe("Display name, e.g. 'Glossary', 'Tech Debt', 'API Endpoints'"),
741
+ description: z.string().optional().describe("What this collection is for \u2014 helps humans and AI understand the collection"),
742
+ purpose: z.string().optional().describe("Why this collection exists \u2014 the strategic reason for tracking this knowledge"),
743
+ icon: z.string().optional().describe("Emoji icon for the collection"),
744
+ navGroup: z.enum(["daily", "strategic", "governance", "reference", "collections"]).default("collections").describe("Sidebar placement: 'daily' (operational), 'strategic' (planning), 'governance' (authority), 'reference' (lookup), 'collections' (default)"),
745
+ fields: z.array(z.object({
746
+ key: z.string().describe("Field key, e.g. 'description', 'severity', 'status'"),
747
+ label: z.string().describe("Display label, e.g. 'Description', 'Severity'"),
748
+ type: z.string().describe("Field type: 'string', 'select', 'array', 'number', 'boolean'"),
749
+ required: z.boolean().optional().describe("Whether this field is required"),
750
+ options: z.array(z.string()).optional().describe("Options for 'select' type fields"),
751
+ searchable: z.boolean().optional().describe("Whether this field is included in full-text search")
752
+ })).describe("Field definitions for the collection schema")
753
+ },
754
+ annotations: { destructiveHint: false }
755
+ },
756
+ async ({ slug, name, description, purpose, icon, navGroup, fields }) => {
757
+ requireWriteAccess();
758
+ try {
759
+ await mcpMutation("chain.createCollection", {
760
+ slug,
761
+ name,
762
+ description,
763
+ purpose,
764
+ icon,
765
+ navGroup: navGroup ?? "collections",
766
+ fields,
767
+ createdBy: getAgentSessionId() ? `agent:${getAgentSessionId()}` : "mcp"
768
+ });
769
+ const fieldList = fields.map((f) => ` - \`${f.key}\` (${f.type}${f.required ? ", required" : ""}${f.searchable ? ", searchable" : ""})`).join("\n");
770
+ return {
771
+ content: [{
772
+ type: "text",
773
+ text: `# Collection Created: ${name}
774
+
775
+ **Slug:** \`${slug}\`
776
+ ` + (description ? `**Description:** ${description}
777
+ ` : "") + (purpose ? `**Purpose:** ${purpose}
778
+ ` : "") + `**Nav group:** ${navGroup ?? "collections"}
779
+
780
+ **Fields:**
781
+ ${fieldList}
782
+
783
+ You can now capture entries: \`capture collection="${slug}" name="..." description="..."\``
784
+ }]
785
+ };
786
+ } catch (error) {
787
+ const msg = error instanceof Error ? error.message : String(error);
788
+ if (msg.includes("already exists")) {
789
+ return {
790
+ content: [{
791
+ type: "text",
792
+ text: `Collection \`${slug}\` already exists. Use \`update-collection\` to modify it, or choose a different slug.`
793
+ }]
794
+ };
795
+ }
796
+ throw error;
797
+ }
798
+ }
799
+ );
800
+ server.registerTool(
801
+ "update-collection",
802
+ {
803
+ title: "Update Collection",
804
+ description: "Update an existing collection's name, description, purpose, icon, navGroup, or field schema. Only provide the fields you want to change. Use `list-collections` to see current state.",
805
+ inputSchema: {
806
+ slug: z.string().describe("Collection slug to update, e.g. 'glossary', 'tech-debt'"),
807
+ name: z.string().optional().describe("New display name"),
808
+ description: z.string().optional().describe("New description"),
809
+ purpose: z.string().optional().describe("New purpose \u2014 why this collection exists"),
810
+ icon: z.string().optional().describe("New emoji icon"),
811
+ navGroup: z.enum(["daily", "strategic", "governance", "reference", "collections"]).optional().describe("New sidebar placement"),
812
+ fields: z.array(z.object({
813
+ key: z.string(),
814
+ label: z.string(),
815
+ type: z.string(),
816
+ required: z.boolean().optional(),
817
+ options: z.array(z.string()).optional(),
818
+ searchable: z.boolean().optional()
819
+ })).optional().describe("Replacement field schema (replaces all fields \u2014 include existing fields you want to keep)")
820
+ },
821
+ annotations: { destructiveHint: false }
822
+ },
823
+ async ({ slug, name, description, purpose, icon, navGroup, fields }) => {
824
+ requireWriteAccess();
825
+ await mcpMutation("chain.updateCollection", {
826
+ slug,
827
+ ...name !== void 0 && { name },
828
+ ...description !== void 0 && { description },
829
+ ...purpose !== void 0 && { purpose },
830
+ ...icon !== void 0 && { icon },
831
+ ...navGroup !== void 0 && { navGroup },
832
+ ...fields !== void 0 && { fields }
833
+ });
834
+ const changes = [name && "name", description && "description", purpose && "purpose", icon && "icon", navGroup && "navGroup", fields && "fields"].filter(Boolean).join(", ");
835
+ return {
836
+ content: [{
837
+ type: "text",
838
+ text: `# Collection Updated: \`${slug}\`
839
+
840
+ Changed: ${changes || "no changes"}.
841
+
842
+ Use \`list-collections\` to verify the result.`
843
+ }]
844
+ };
845
+ }
846
+ );
847
+ server.registerTool(
848
+ "commit-entry",
849
+ {
850
+ title: "Commit Entry to Chain",
851
+ description: "Promote a draft entry to committed status (SSOT on the Chain). Runs a keyword contradiction check against governance entries before committing. Warnings are advisory \u2014 they do not block the commit.\n\nUse after capture + suggest-links + relate-entries to finalize an entry.",
852
+ inputSchema: {
853
+ entryId: z.string().describe("Entry ID to commit, e.g. 'TEN-abc123', 'GT-019'")
854
+ },
855
+ annotations: { destructiveHint: false }
856
+ },
857
+ async ({ entryId }) => {
858
+ requireWriteAccess();
859
+ const { runContradictionCheck } = await import("./smart-capture-2N7E4OAJ.js");
860
+ const entry = await mcpQuery("chain.getEntry", { entryId });
861
+ if (!entry) {
862
+ return {
863
+ content: [{ type: "text", text: `Entry \`${entryId}\` not found. Try search to find the right ID.` }]
864
+ };
865
+ }
866
+ const descField = entry.data?.description ?? entry.data?.canonical ?? entry.data?.rationale ?? "";
867
+ const warnings = await runContradictionCheck(entry.name, descField);
868
+ if (warnings.length > 0) {
869
+ await recordSessionActivity({ contradictionWarning: true });
870
+ }
871
+ const result = await mcpMutation("chain.commitEntry", {
872
+ entryId,
873
+ author: getAgentSessionId() ? `agent:${getAgentSessionId()}` : void 0
874
+ });
875
+ const docId = result?._id ?? entry._id;
876
+ await recordSessionActivity({ entryModified: docId });
877
+ const wsCtx = await getWorkspaceContext();
878
+ let lines;
879
+ if (result?.status === "proposal_created") {
880
+ lines = [
881
+ `# Proposal created: ${result.entryId ?? entryId}`,
882
+ `**${result.name ?? entry.name}** \u2014 commit requires consent. A proposal was created instead of committing directly.`,
883
+ `**Workspace:** ${wsCtx.workspaceSlug} (${wsCtx.workspaceId})`,
884
+ "",
885
+ result.existing ? "An open proposal for this entry already exists." : "Affected owners will be notified. The entry will be committed when the consent window expires with no objections, or when all owners approve."
886
+ ];
887
+ } else {
888
+ lines = [
889
+ `# Committed: ${entryId}`,
890
+ `**${entry.name}** promoted to SSOT on the Chain.`,
891
+ `**Workspace:** ${wsCtx.workspaceSlug} (${wsCtx.workspaceId})`
892
+ ];
893
+ }
894
+ if (warnings.length > 0) {
895
+ lines.push("");
896
+ lines.push("\u26A0 Contradiction check: proposed entry matched existing governance entries:");
897
+ for (const w of warnings) {
898
+ lines.push(`- ${w.name} (${w.collection}, ${w.entryId}) \u2014 has 'governs' relation to ${w.governsCount} entries`);
899
+ }
900
+ lines.push("Run gather-context on these entries before committing.");
901
+ }
902
+ return { content: [{ type: "text", text: lines.join("\n") }] };
903
+ }
904
+ );
905
+ }
906
+
907
+ // src/tools/labels.ts
908
+ import { z as z2 } from "zod";
909
+ function registerLabelTools(server) {
910
+ server.registerTool(
911
+ "labels",
912
+ {
913
+ title: "Labels",
914
+ description: "Manage workspace labels \u2014 list, create, update, delete, apply to entries, or remove from entries. Labels can be applied to any entry across any collection for cross-domain filtering. Similar to labels in Linear or GitHub. Labels support hierarchy (groups with children).",
915
+ inputSchema: {
916
+ action: z2.enum(["list", "create", "update", "delete", "apply", "remove"]).describe("Action: list all labels, create/update/delete a label, or apply/remove a label on an entry"),
917
+ slug: z2.string().optional().describe("Label slug (required for create/update/delete/apply/remove)"),
918
+ name: z2.string().optional().describe("Display name (required for create)"),
919
+ color: z2.string().optional().describe("Hex color, e.g. '#ef4444'"),
920
+ description: z2.string().optional().describe("What this label means"),
921
+ parentSlug: z2.string().optional().describe("Parent group slug for label hierarchy"),
922
+ isGroup: z2.boolean().optional().describe("True if this is a group container, not a taggable label"),
923
+ order: z2.number().optional().describe("Sort order within its group"),
924
+ entryId: z2.string().optional().describe("Entry ID for apply/remove actions")
925
+ }
926
+ },
927
+ async ({ action, slug, name, color, description, parentSlug, isGroup, order, entryId }) => {
928
+ if (action === "list") {
929
+ const labels = await mcpQuery("chain.listLabels");
930
+ if (labels.length === 0) {
931
+ return { content: [{ type: "text", text: "No labels defined in this workspace yet." }] };
932
+ }
933
+ const groups = labels.filter((l) => l.isGroup);
934
+ const ungrouped = labels.filter((l) => !l.isGroup && !l.parentId);
935
+ const children = (parentId) => labels.filter((l) => l.parentId === parentId);
936
+ const lines = ["# Workspace Labels"];
937
+ for (const group of groups) {
938
+ lines.push(`
939
+ ## ${group.name}`);
940
+ if (group.description) lines.push(`_${group.description}_`);
941
+ for (const child of children(group._id)) {
942
+ const c = child.color ? ` ${child.color}` : "";
943
+ lines.push(` - \`${child.slug}\` ${child.name}${c}`);
944
+ }
945
+ }
946
+ if (ungrouped.length > 0) {
947
+ lines.push("\n## Ungrouped");
948
+ for (const label of ungrouped) {
949
+ const c = label.color ? ` ${label.color}` : "";
950
+ lines.push(`- \`${label.slug}\` ${label.name}${c}${label.description ? ` \u2014 _${label.description}_` : ""}`);
951
+ }
952
+ }
953
+ return { content: [{ type: "text", text: lines.join("\n") }] };
954
+ }
955
+ if (!slug) {
956
+ return { content: [{ type: "text", text: "A `slug` is required for this action." }] };
957
+ }
958
+ if (action === "create") {
959
+ if (!name) {
960
+ return { content: [{ type: "text", text: "Cannot create a label without a name." }] };
961
+ }
962
+ let parentId;
963
+ if (parentSlug) {
964
+ const labels = await mcpQuery("chain.listLabels");
965
+ const parent = labels.find((l) => l.slug === parentSlug);
966
+ if (!parent) {
967
+ return { content: [{ type: "text", text: `Parent label \`${parentSlug}\` not found. Use \`labels action=list\` to see available groups.` }] };
968
+ }
969
+ parentId = parent._id;
970
+ }
971
+ await mcpMutation("chain.createLabel", { slug, name, color, description, parentId, isGroup, order });
972
+ return { content: [{ type: "text", text: `# Label Created
973
+
974
+ **${name}** (\`${slug}\`)` }] };
975
+ }
976
+ if (action === "update") {
977
+ await mcpMutation("chain.updateLabel", { slug, name, color, description, isGroup, order });
978
+ return { content: [{ type: "text", text: `# Label Updated
979
+
980
+ \`${slug}\` has been updated.` }] };
981
+ }
982
+ if (action === "delete") {
983
+ await mcpMutation("chain.deleteLabel", { slug });
984
+ return { content: [{ type: "text", text: `# Label Deleted
985
+
986
+ \`${slug}\` removed from all entries and deleted.` }] };
987
+ }
988
+ if (action === "apply" || action === "remove") {
989
+ if (!entryId) {
990
+ return { content: [{ type: "text", text: "An `entryId` is required for apply/remove actions." }] };
991
+ }
992
+ if (action === "apply") {
993
+ await mcpMutation("chain.applyLabel", { entryId, labelSlug: slug });
994
+ return { content: [{ type: "text", text: `Label \`${slug}\` applied to **${entryId}**.` }] };
995
+ }
996
+ await mcpMutation("chain.removeLabel", { entryId, labelSlug: slug });
997
+ return { content: [{ type: "text", text: `Label \`${slug}\` removed from **${entryId}**.` }] };
998
+ }
999
+ return { content: [{ type: "text", text: "Unknown action." }] };
1000
+ }
1001
+ );
1002
+ }
1003
+
1004
+ // src/tools/health.ts
1005
+ import { z as z3 } from "zod";
1006
+
1007
+ // src/tools/planned-work.ts
1008
+ async function queryPlannedWork() {
1009
+ const result = {
1010
+ uncommittedDrafts: [],
1011
+ inProgressEntries: [],
1012
+ openTensions: []
1013
+ };
1014
+ try {
1015
+ const allEntries = await mcpQuery("chain.listEntries", {});
1016
+ if (!allEntries) return result;
1017
+ for (const entry of allEntries) {
1018
+ if (entry.stratum === "system") continue;
1019
+ const collection = entry.collectionSlug ?? entry.collection ?? "unknown";
1020
+ if (entry.status === "draft") {
1021
+ if (collection === "tensions") {
1022
+ result.openTensions.push({ name: entry.name, entryId: entry.entryId ?? entry._id });
1023
+ } else {
1024
+ result.uncommittedDrafts.push({ name: entry.name, collection });
1025
+ }
1026
+ }
1027
+ if (entry.status === "in-progress" || entry.status === "in_progress") {
1028
+ result.inProgressEntries.push({
1029
+ name: entry.name,
1030
+ collection,
1031
+ entryId: entry.entryId ?? entry._id
1032
+ });
1033
+ }
1034
+ }
1035
+ } catch {
1036
+ }
1037
+ return result;
1038
+ }
1039
+ function hasPlannedWork(work) {
1040
+ return work.uncommittedDrafts.length > 0 || work.inProgressEntries.length > 0 || work.openTensions.length > 0;
1041
+ }
1042
+ function buildPlannedWorkSection(work, priorSessions) {
1043
+ if (!hasPlannedWork(work) && priorSessions.length === 0) return [];
1044
+ const lines = [];
1045
+ lines.push("## Continue from where you left off");
1046
+ lines.push("");
1047
+ if (work.inProgressEntries.length > 0) {
1048
+ const top = work.inProgressEntries[0];
1049
+ lines.push(`- **Active: ${top.name}** (${top.collection}) \u2014 want to continue?`);
1050
+ for (const entry of work.inProgressEntries.slice(1, 3)) {
1051
+ lines.push(`- ${entry.name} (${entry.collection})`);
1052
+ }
1053
+ if (work.inProgressEntries.length > 3) {
1054
+ lines.push(`- _...and ${work.inProgressEntries.length - 3} more in progress_`);
1055
+ }
1056
+ }
1057
+ if (work.uncommittedDrafts.length > 0) {
1058
+ const count = work.uncommittedDrafts.length;
1059
+ const label = count === 1 ? "1 uncommitted draft" : `${count} uncommitted drafts`;
1060
+ const topNames = work.uncommittedDrafts.slice(0, 3).map((d) => d.name).join(", ");
1061
+ lines.push(`- **${label}** \u2014 ${topNames}${count > 3 ? ", ..." : ""}`);
1062
+ }
1063
+ if (work.openTensions.length > 0) {
1064
+ const top = work.openTensions[0];
1065
+ const count = work.openTensions.length;
1066
+ if (count === 1) {
1067
+ lines.push(`- **Open tension: ${top.name}** \u2014 discuss or capture a decision?`);
1068
+ } else {
1069
+ lines.push(`- **${count} open tensions** \u2014 top: ${top.name}`);
1070
+ }
1071
+ }
1072
+ if (priorSessions.length > 0 && !hasPlannedWork(work)) {
1073
+ const last = priorSessions[0];
1074
+ const date = new Date(last.startedAt).toISOString().split("T")[0];
1075
+ const created = Array.isArray(last.entriesCreated) ? last.entriesCreated.length : last.entriesCreated ?? 0;
1076
+ const modified = Array.isArray(last.entriesModified) ? last.entriesModified.length : last.entriesModified ?? 0;
1077
+ if (created > 0 || modified > 0) {
1078
+ lines.push(`- **Last session** (${date}): ${created} created, ${modified} modified`);
1079
+ }
1080
+ }
1081
+ lines.push("");
1082
+ return lines;
1083
+ }
1084
+
1085
+ // src/tools/health.ts
1086
+ var CALL_CATEGORIES = {
1087
+ "chain.getEntry": "read",
1088
+ "chain.listEntries": "read",
1089
+ "chain.listEntryHistory": "read",
1090
+ "chain.listEntryRelations": "read",
1091
+ "chain.listEntriesByLabel": "read",
1092
+ "chain.searchEntries": "search",
1093
+ "chain.createEntry": "write",
1094
+ "chain.updateEntry": "write",
1095
+ "chain.createEntryRelation": "write",
1096
+ "chain.applyLabel": "label",
1097
+ "chain.removeLabel": "label",
1098
+ "chain.createLabel": "label",
1099
+ "chain.updateLabel": "label",
1100
+ "chain.deleteLabel": "label",
1101
+ "chain.createCollection": "write",
1102
+ "chain.updateCollection": "write",
1103
+ "chain.listCollections": "meta",
1104
+ "chain.getCollection": "meta",
1105
+ "chain.listLabels": "meta",
1106
+ "resolveWorkspace": "meta"
1107
+ };
1108
+ function categorize(fn) {
1109
+ return CALL_CATEGORIES[fn] ?? "meta";
1110
+ }
1111
+ function formatDuration(ms) {
1112
+ if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
1113
+ const mins = Math.floor(ms / 6e4);
1114
+ const secs = Math.round(ms % 6e4 / 1e3);
1115
+ return `${mins}m ${secs}s`;
1116
+ }
1117
+ function buildSessionSummary(log) {
1118
+ if (log.length === 0) return "";
1119
+ const byCategory = /* @__PURE__ */ new Map();
1120
+ let errorCount = 0;
1121
+ let writeCreates = 0;
1122
+ let writeUpdates = 0;
1123
+ for (const entry of log) {
1124
+ const cat = categorize(entry.fn);
1125
+ if (!byCategory.has(cat)) byCategory.set(cat, /* @__PURE__ */ new Map());
1126
+ const fnCounts = byCategory.get(cat);
1127
+ fnCounts.set(entry.fn, (fnCounts.get(entry.fn) ?? 0) + 1);
1128
+ if (entry.status === "error") errorCount++;
1129
+ if (entry.fn === "chain.createEntry" && entry.status === "ok") writeCreates++;
1130
+ if (entry.fn === "chain.updateEntry" && entry.status === "ok") writeUpdates++;
1131
+ }
1132
+ const firstTs = new Date(log[0].ts).getTime();
1133
+ const lastTs = new Date(log[log.length - 1].ts).getTime();
1134
+ const duration = formatDuration(lastTs - firstTs);
1135
+ const lines = [`# Session Summary (${duration})
1136
+ `];
1137
+ const categoryLabels = [
1138
+ ["read", "Reads"],
1139
+ ["search", "Searches"],
1140
+ ["write", "Writes"],
1141
+ ["label", "Labels"],
1142
+ ["meta", "Meta"]
1143
+ ];
1144
+ for (const [cat, label] of categoryLabels) {
1145
+ const fnCounts = byCategory.get(cat);
1146
+ if (!fnCounts || fnCounts.size === 0) continue;
1147
+ const total = [...fnCounts.values()].reduce((a, b) => a + b, 0);
1148
+ const detail = [...fnCounts.entries()].sort((a, b) => b[1] - a[1]).map(([fn, count]) => `${fn.replace("chain.", "")} x${count}`).join(", ");
1149
+ lines.push(`- **${label}:** ${total} call${total === 1 ? "" : "s"} (${detail})`);
1150
+ }
1151
+ lines.push(`- **Errors:** ${errorCount}`);
1152
+ if (writeCreates > 0 || writeUpdates > 0) {
1153
+ lines.push("");
1154
+ lines.push("## Knowledge Contribution");
1155
+ if (writeCreates > 0) lines.push(`- ${writeCreates} entr${writeCreates === 1 ? "y" : "ies"} created`);
1156
+ if (writeUpdates > 0) lines.push(`- ${writeUpdates} entr${writeUpdates === 1 ? "y" : "ies"} updated`);
1157
+ }
1158
+ return lines.join("\n");
1159
+ }
1160
+ function registerHealthTools(server) {
1161
+ server.registerTool(
1162
+ "health",
1163
+ {
1164
+ title: "Health Check",
1165
+ description: "Verify that Product Brain is running and can reach its backend. Returns workspace status, collection count, entry count, and latency. Use this to confirm connectivity before doing real work.",
1166
+ annotations: { readOnlyHint: true }
1167
+ },
1168
+ async () => {
1169
+ const start = Date.now();
1170
+ const errors = [];
1171
+ let workspaceId;
1172
+ try {
1173
+ workspaceId = await getWorkspaceId();
1174
+ } catch (e) {
1175
+ errors.push(`Workspace resolution failed: ${e.message}`);
1176
+ }
1177
+ let collections = [];
1178
+ try {
1179
+ collections = await mcpQuery("chain.listCollections");
1180
+ } catch (e) {
1181
+ errors.push(`Collection fetch failed: ${e.message}`);
1182
+ }
1183
+ let totalEntries = 0;
1184
+ if (collections.length > 0) {
1185
+ try {
1186
+ const entries = await mcpQuery("chain.listEntries", {});
1187
+ totalEntries = entries.length;
1188
+ } catch (e) {
1189
+ errors.push(`Entry count failed: ${e.message}`);
1190
+ }
1191
+ }
1192
+ let wsCtx = null;
1193
+ try {
1194
+ wsCtx = await getWorkspaceContext();
1195
+ } catch {
1196
+ }
1197
+ const durationMs = Date.now() - start;
1198
+ const healthy = errors.length === 0;
1199
+ const lines = [
1200
+ `# ${healthy ? "Healthy" : "Degraded"}`,
1201
+ "",
1202
+ `**Workspace:** ${workspaceId ?? "unresolved"}`,
1203
+ `**Workspace Slug:** ${wsCtx?.workspaceSlug ?? "unknown"}`,
1204
+ `**Workspace Name:** ${wsCtx?.workspaceName ?? "unknown"}`,
1205
+ `**Collections:** ${collections.length}`,
1206
+ `**Entries:** ${totalEntries}`,
1207
+ `**Latency:** ${durationMs}ms`
1208
+ ];
1209
+ if (errors.length > 0) {
1210
+ lines.push("", "## Errors");
1211
+ for (const err of errors) {
1212
+ lines.push(`- ${err}`);
1213
+ }
1214
+ }
1215
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1216
+ }
1217
+ );
1218
+ server.registerTool(
1219
+ "whoami",
1220
+ {
1221
+ title: "Session Identity",
1222
+ description: "Returns the current workspace and auth context for this MCP session. Use at the start of a session to confirm you're operating on the right workspace before making writes.",
1223
+ annotations: { readOnlyHint: true }
1224
+ },
1225
+ async () => {
1226
+ const ctx = await getWorkspaceContext();
1227
+ const lines = [
1228
+ `# Session Identity`,
1229
+ "",
1230
+ `**Workspace ID:** ${ctx.workspaceId}`,
1231
+ `**Workspace Slug:** ${ctx.workspaceSlug}`,
1232
+ `**Workspace Name:** ${ctx.workspaceName}`
1233
+ ];
1234
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1235
+ }
1236
+ );
1237
+ server.registerTool(
1238
+ "workspace-status",
1239
+ {
1240
+ title: "Workspace Status",
1241
+ description: "The 'Monday morning' tool \u2014 returns workspace readiness score, specific gaps with suggested next actions, and workspace stats (entries, relations, orphans, drafts).\n\nUse this to understand how ready the workspace is, what foundational knowledge is missing, and what to work on next. Great for starting a session or planning knowledge work.",
1242
+ annotations: { readOnlyHint: true }
1243
+ },
1244
+ async () => {
1245
+ const result = await mcpQuery("chain.workspaceReadiness");
1246
+ const { score, totalChecks, passedChecks, checks, gaps, stats, governanceMode } = result;
1247
+ const scoreBar = "\u2588".repeat(Math.round(score / 10)) + "\u2591".repeat(10 - Math.round(score / 10));
1248
+ const lines = [
1249
+ `# Workspace Readiness: ${score}%`,
1250
+ `${scoreBar} ${passedChecks}/${totalChecks} requirements met`,
1251
+ `
1252
+ **Governance:** ${governanceMode ?? "open"}${(governanceMode ?? "open") !== "open" ? " (commits require proposal)" : ""}`,
1253
+ "",
1254
+ "## Stats",
1255
+ `- **Entries:** ${stats.totalEntries} (${stats.activeCount} active, ${stats.draftCount} draft)`,
1256
+ `- **Relations:** ${stats.totalRelations}`,
1257
+ `- **Collections:** ${stats.collectionCount}`,
1258
+ `- **Orphaned:** ${stats.orphanedCount} committed entries with no relations`,
1259
+ ""
1260
+ ];
1261
+ if (gaps.length > 0) {
1262
+ lines.push("## Gaps (action required)");
1263
+ for (const gap of gaps) {
1264
+ lines.push(`- [ ] **${gap.label}** \u2014 ${gap.description}`);
1265
+ lines.push(` ${gap.current}/${gap.required} | _${gap.guidance}_`);
1266
+ }
1267
+ lines.push("");
1268
+ }
1269
+ const passed = checks.filter((c) => c.passed);
1270
+ if (passed.length > 0) {
1271
+ lines.push("## Passed");
1272
+ for (const check of passed) {
1273
+ lines.push(`- [x] **${check.label}** (${check.current}/${check.required})`);
1274
+ }
1275
+ }
1276
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1277
+ }
1278
+ );
1279
+ server.registerTool(
1280
+ "orient",
1281
+ {
1282
+ title: "Orient \u2014 Start Here",
1283
+ description: "The single entry point for starting a session. Returns workspace context with a single recommended next action for low-readiness workspaces, or a standup-style briefing for established workspaces.\n\nUse this FIRST. One call to orient replaces 3\u20135 individual tool calls.\n\nCompleting orientation unlocks write tools for the active session.",
1284
+ annotations: { readOnlyHint: true }
1285
+ },
1286
+ async () => {
1287
+ const errors = [];
1288
+ const agentSessionId = getAgentSessionId();
1289
+ let wsCtx = null;
1290
+ try {
1291
+ wsCtx = await getWorkspaceContext();
1292
+ } catch (e) {
1293
+ errors.push(`Workspace: ${e.message}`);
1294
+ }
1295
+ let priorSessions = [];
1296
+ if (wsCtx) {
1297
+ try {
1298
+ priorSessions = await mcpQuery("agent.recentSessions", { limit: 3 });
1299
+ } catch {
1300
+ }
1301
+ }
1302
+ let orientEntries = null;
1303
+ try {
1304
+ orientEntries = await mcpQuery("chain.getOrientEntries");
1305
+ } catch {
1306
+ }
1307
+ let openTensions = [];
1308
+ try {
1309
+ const tensions = await mcpQuery("chain.listEntries", { collectionSlug: "tensions" });
1310
+ openTensions = (tensions ?? []).filter((e) => e.status === "draft");
1311
+ } catch {
1312
+ }
1313
+ let readiness = null;
1314
+ try {
1315
+ readiness = await mcpQuery("chain.workspaceReadiness");
1316
+ } catch (e) {
1317
+ errors.push(`Readiness: ${e.message}`);
1318
+ }
1319
+ const lines = [];
1320
+ const isLowReadiness = readiness && readiness.score < 50;
1321
+ if (wsCtx) {
1322
+ lines.push(`# ${wsCtx.workspaceName}`);
1323
+ lines.push(`_Workspace \`${wsCtx.workspaceSlug}\` \u2014 Product Brain is healthy._`);
1324
+ } else {
1325
+ lines.push("# Workspace");
1326
+ lines.push("_Could not resolve workspace._");
1327
+ }
1328
+ lines.push("");
1329
+ if (isLowReadiness && wsCtx?.createdAt) {
1330
+ const ageDays = Math.floor((Date.now() - wsCtx.createdAt) / (1e3 * 60 * 60 * 24));
1331
+ if (ageDays >= 30) {
1332
+ lines.push(`Your workspace has been around for ${ageDays} days but is only ${readiness.score}% ready.`);
1333
+ lines.push("Let's close the gaps \u2014 or if the current structure doesn't fit, we can reshape it.");
1334
+ lines.push("");
1335
+ }
1336
+ }
1337
+ if (isLowReadiness) {
1338
+ lines.push(`Readiness: ${readiness.score}% (${readiness.passedChecks}/${readiness.totalChecks}).`);
1339
+ lines.push("");
1340
+ const gaps = readiness.gaps ?? [];
1341
+ if (gaps.length > 0) {
1342
+ const gap = gaps[0];
1343
+ const ctaMap = {
1344
+ "strategy-vision": "Tell me what you're building \u2014 your vision, mission, and north star \u2014 and I'll capture it.",
1345
+ "architecture-layers": "Describe your architecture in a few sentences and I'll capture it.",
1346
+ "glossary-foundation": "What are the key terms your team uses? Tell me a few and I'll add them to the glossary.",
1347
+ "decisions-documented": "What's a recent significant decision your team made? I'll document it with the rationale.",
1348
+ "tensions-tracked": "What's a friction point or pain point you're dealing with? I'll capture it as a tension."
1349
+ };
1350
+ const cta = ctaMap[gap.id] ?? `Tell me about your ${gap.label.toLowerCase()} and I'll capture it.`;
1351
+ lines.push("## Recommended next step");
1352
+ lines.push(`**${gap.label}** (${gap.current}/${gap.required})`);
1353
+ lines.push("");
1354
+ lines.push(cta);
1355
+ lines.push("");
1356
+ lines.push('_Everything stays as a draft until you confirm. Say "commit" or "looks good" to promote to the Chain._');
1357
+ lines.push("");
1358
+ const remainingGaps = gaps.length - 1;
1359
+ if (remainingGaps > 0 || openTensions.length > 0) {
1360
+ lines.push(`_${remainingGaps > 0 ? `${remainingGaps} more gap${remainingGaps === 1 ? "" : "s"}` : ""}${remainingGaps > 0 && openTensions.length > 0 ? " and " : ""}${openTensions.length > 0 ? `${openTensions.length} open tension${openTensions.length === 1 ? "" : "s"}` : ""} \u2014 ask "show full status" for details._`);
1361
+ lines.push("");
1362
+ }
1363
+ }
1364
+ lines.push("_Need a collection that doesn't exist yet (e.g. audiences, personas, metrics)?_");
1365
+ lines.push("_Use `create-collection` to add it, or ask me to propose collections for your domain._");
1366
+ lines.push("");
1367
+ } else if (readiness) {
1368
+ lines.push(`Readiness: ${readiness.score}% (${readiness.passedChecks}/${readiness.totalChecks}).`);
1369
+ lines.push("");
1370
+ if (orientEntries) {
1371
+ const fmt = (e) => {
1372
+ const type = e.canonicalKey ?? "generic";
1373
+ const stratum = e.stratum ?? "?";
1374
+ return `- \`${e.entryId ?? e._id}\` [${type} \xB7 ${stratum}] ${e.name}`;
1375
+ };
1376
+ if (orientEntries.activeBets?.length > 0) {
1377
+ lines.push("## Active bets");
1378
+ orientEntries.activeBets.forEach((e) => lines.push(fmt(e)));
1379
+ lines.push("");
1380
+ }
1381
+ if (orientEntries.activeGoals.length > 0) {
1382
+ lines.push("## Active goals");
1383
+ orientEntries.activeGoals.forEach((e) => lines.push(fmt(e)));
1384
+ lines.push("");
1385
+ }
1386
+ if (orientEntries.recentDecisions.length > 0) {
1387
+ lines.push("## Recent decisions");
1388
+ orientEntries.recentDecisions.forEach((e) => lines.push(fmt(e)));
1389
+ lines.push("");
1390
+ }
1391
+ if (orientEntries.recentlySuperseded.length > 0) {
1392
+ lines.push("## Recently superseded");
1393
+ orientEntries.recentlySuperseded.forEach((e) => lines.push(fmt(e)));
1394
+ lines.push("");
1395
+ }
1396
+ if (orientEntries.staleEntries.length > 0) {
1397
+ lines.push("## Needs confirmation");
1398
+ lines.push(`_Domain stratum entries not confirmed in ${orientEntries.stalenessThresholdDays} days._`);
1399
+ orientEntries.staleEntries.forEach((e) => lines.push(fmt(e)));
1400
+ lines.push("");
1401
+ }
1402
+ if (orientEntries.businessRules.length > 0) {
1403
+ lines.push("## Business rules");
1404
+ orientEntries.businessRules.forEach((e) => lines.push(fmt(e)));
1405
+ lines.push("");
1406
+ }
1407
+ if (orientEntries.architectureNotes.length > 0) {
1408
+ lines.push("## Architecture notes");
1409
+ orientEntries.architectureNotes.forEach((e) => lines.push(fmt(e)));
1410
+ lines.push("");
1411
+ }
1412
+ }
1413
+ const plannedWork = await queryPlannedWork();
1414
+ if (hasPlannedWork(plannedWork)) {
1415
+ lines.push(...buildPlannedWorkSection(plannedWork, priorSessions));
1416
+ } else {
1417
+ const briefingItems = [];
1418
+ if (priorSessions.length > 0) {
1419
+ const last = priorSessions[0];
1420
+ const date = new Date(last.startedAt).toISOString().split("T")[0];
1421
+ const created = Array.isArray(last.entriesCreated) ? last.entriesCreated.length : last.entriesCreated ?? 0;
1422
+ const modified = Array.isArray(last.entriesModified) ? last.entriesModified.length : last.entriesModified ?? 0;
1423
+ briefingItems.push(`**Last session** (${date}): ${created} created, ${modified} modified`);
1424
+ }
1425
+ if (readiness.gaps?.length > 0) {
1426
+ briefingItems.push(`**${readiness.gaps.length} gap${readiness.gaps.length === 1 ? "" : "s"}** remaining`);
1427
+ }
1428
+ if (briefingItems.length > 0) {
1429
+ lines.push("## Briefing");
1430
+ for (const item of briefingItems) {
1431
+ lines.push(`- ${item}`);
1432
+ }
1433
+ lines.push("");
1434
+ }
1435
+ }
1436
+ lines.push("What would you like to work on?");
1437
+ lines.push("");
1438
+ }
1439
+ if (errors.length > 0) {
1440
+ lines.push("## Errors");
1441
+ for (const err of errors) lines.push(`- ${err}`);
1442
+ lines.push("");
1443
+ }
1444
+ if (agentSessionId) {
1445
+ try {
1446
+ await mcpCall("agent.markOriented", { sessionId: agentSessionId });
1447
+ setSessionOriented(true);
1448
+ lines.push("---");
1449
+ lines.push(`Orientation complete. Session ${agentSessionId}. Write tools available.`);
1450
+ } catch {
1451
+ lines.push("---");
1452
+ lines.push("_Warning: Could not mark session as oriented. Write tools may be restricted._");
1453
+ }
1454
+ try {
1455
+ await mcpMutation("chain.recordSessionSignal", {
1456
+ sessionId: agentSessionId,
1457
+ signalType: "immediate_context_load",
1458
+ metadata: { source: "orient" }
1459
+ });
1460
+ } catch (err) {
1461
+ process.stderr.write(`[MCP] recordSessionSignal failed: ${err.message}
1462
+ `);
1463
+ }
1464
+ } else {
1465
+ lines.push("---");
1466
+ lines.push("_No active agent session. Call `agent-start` to begin a tracked session._");
1467
+ }
1468
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1469
+ }
1470
+ );
1471
+ server.registerTool(
1472
+ "mcp-audit",
1473
+ {
1474
+ title: "Session Audit Log",
1475
+ description: "Show a session summary (reads, writes, searches, contributions) and the last N backend calls. Useful for debugging, tracing tool behavior, and seeing what you contributed to the chain.",
1476
+ inputSchema: {
1477
+ limit: z3.number().min(1).max(50).default(20).describe("How many recent calls to show (max 50)")
1478
+ },
1479
+ annotations: { readOnlyHint: true }
1480
+ },
1481
+ async ({ limit }) => {
1482
+ const log = getAuditLog();
1483
+ const recent = log.slice(-limit);
1484
+ if (recent.length === 0) {
1485
+ return { content: [{ type: "text", text: "No calls recorded yet this session." }] };
1486
+ }
1487
+ const summary = buildSessionSummary(log);
1488
+ const logLines = [`# Audit Log (last ${recent.length} of ${log.length} total)
1489
+ `];
1490
+ for (const entry of recent) {
1491
+ const icon = entry.status === "ok" ? "\u2713" : "\u2717";
1492
+ const errPart = entry.error ? ` \u2014 ${entry.error}` : "";
1493
+ logLines.push(`${icon} \`${entry.fn}\` ${entry.durationMs}ms ${entry.status}${errPart}`);
1494
+ }
1495
+ return {
1496
+ content: [{ type: "text", text: `${summary}
1497
+
1498
+ ---
1499
+
1500
+ ${logLines.join("\n")}` }]
1501
+ };
1502
+ }
1503
+ );
1504
+ }
1505
+
1506
+ // src/tools/verify.ts
1507
+ import { existsSync, readFileSync } from "fs";
1508
+ import { resolve } from "path";
1509
+ import { z as z4 } from "zod";
1510
+ function resolveProjectRoot() {
1511
+ const candidates = [
1512
+ process.env.WORKSPACE_PATH,
1513
+ process.cwd(),
1514
+ resolve(process.cwd(), "..")
1515
+ ].filter(Boolean);
1516
+ for (const dir of candidates) {
1517
+ const resolved = resolve(dir);
1518
+ if (existsSync(resolve(resolved, "convex/schema.ts"))) return resolved;
1519
+ }
1520
+ return null;
1521
+ }
1522
+ function parseConvexSchema(schemaPath) {
1523
+ const content = readFileSync(schemaPath, "utf-8");
1524
+ const tables = /* @__PURE__ */ new Map();
1525
+ let currentTable = null;
1526
+ for (const line of content.split("\n")) {
1527
+ const tableMatch = line.match(/^\t(\w+):\s*defineTable\(/);
1528
+ if (tableMatch) {
1529
+ currentTable = tableMatch[1];
1530
+ tables.set(currentTable, /* @__PURE__ */ new Set());
1531
+ continue;
1532
+ }
1533
+ if (currentTable && /^\t[}\)]/.test(line) && !/^\t\t/.test(line)) {
1534
+ currentTable = null;
1535
+ continue;
1536
+ }
1537
+ if (currentTable) {
1538
+ const fieldMatch = line.match(/^\t\t(\w+):\s*v\./);
1539
+ if (fieldMatch) tables.get(currentTable).add(fieldMatch[1]);
1540
+ }
1541
+ }
1542
+ return tables;
1543
+ }
1544
+ function cleanFieldRef(field) {
1545
+ return field.replace(/\s*\(.*\)\s*$/, "").replace(/\s*=\s*.*$/, "").trim();
1546
+ }
1547
+ function splitMultiRefs(field) {
1548
+ if (field.includes(" + ")) return field.split(/\s*\+\s*/);
1549
+ return [field];
1550
+ }
1551
+ var FILE_EXT_RE = /\.(ts|js|svelte|json|css|md|jsx|tsx|mjs|cjs)(?:\s|$)/i;
1552
+ function classifyRef(cleaned) {
1553
+ if (cleaned.includes("/") || FILE_EXT_RE.test(cleaned) || /^\.\w+/.test(cleaned)) {
1554
+ return "file";
1555
+ }
1556
+ if (/^\w+\.\w+$/.test(cleaned)) return "schema";
1557
+ if (/^\w+\s+table$/i.test(cleaned)) return "schema";
1558
+ return "conceptual";
1559
+ }
1560
+ function checkFileRef(ref, root) {
1561
+ const filePart = ref.split(/\s+/)[0];
1562
+ const fullPath = resolve(root, filePart);
1563
+ if (existsSync(fullPath)) return { result: "verified", reason: "exists" };
1564
+ return { result: "drifted", reason: `not found: ${filePart}` };
1565
+ }
1566
+ function checkSchemaRef(ref, schema) {
1567
+ const tableOnlyMatch = ref.match(/^(\w+)\s+table$/i);
1568
+ if (tableOnlyMatch) {
1569
+ const table = tableOnlyMatch[1];
1570
+ if (schema.has(table)) return { result: "verified", reason: `table "${table}" exists` };
1571
+ return { result: "drifted", reason: `table "${table}" not found in schema` };
1572
+ }
1573
+ const dotIdx = ref.indexOf(".");
1574
+ if (dotIdx > 0) {
1575
+ const table = ref.slice(0, dotIdx);
1576
+ const field = ref.slice(dotIdx + 1);
1577
+ if (!schema.has(table)) return { result: "drifted", reason: `table "${table}" not found in schema` };
1578
+ if (!schema.get(table).has(field)) return { result: "drifted", reason: `field "${field}" not found in table "${table}"` };
1579
+ return { result: "verified", reason: `${table}.${field} exists` };
1580
+ }
1581
+ return { result: "unverifiable", reason: "could not parse schema reference" };
1582
+ }
1583
+ function formatTrustReport(collection, entryCount, mappings, refs, fixes, mode, schemaTableCount, projectRoot) {
1584
+ const verified = mappings.filter((c) => c.result === "verified").length;
1585
+ const drifted = mappings.filter((c) => c.result === "drifted").length;
1586
+ const unverifiable = mappings.filter((c) => c.result === "unverifiable").length;
1587
+ const refsValid = refs.filter((c) => c.found).length;
1588
+ const refsBroken = refs.filter((c) => !c.found).length;
1589
+ const totalChecks = mappings.length + refs.length;
1590
+ const totalPassed = verified + refsValid;
1591
+ const trustScore = totalChecks > 0 ? Math.round(totalPassed / totalChecks * 100) : 100;
1592
+ const lines = [
1593
+ `# Trust Report: ${collection} (${entryCount} entries scanned)`,
1594
+ ""
1595
+ ];
1596
+ if (mappings.length > 0) {
1597
+ lines.push(
1598
+ "## Code Mapping Verification",
1599
+ `- ${mappings.length} mappings checked`,
1600
+ `- ${verified} verified (${Math.round(verified / mappings.length * 100)}%)`,
1601
+ `- ${drifted} drifted (file/schema not found)`,
1602
+ `- ${unverifiable} unverifiable (conceptual \u2014 skipped)`,
1603
+ ""
1604
+ );
1605
+ }
1606
+ if (drifted > 0) {
1607
+ lines.push("### Drifted Mappings");
1608
+ for (const mc of mappings.filter((c) => c.result === "drifted")) {
1609
+ lines.push(`- **${mc.entryId}** (${mc.entryName}): \`${mc.field}\` \u2014 ${mc.reason}`);
1610
+ }
1611
+ lines.push("");
1612
+ }
1613
+ if (unverifiable > 0) {
1614
+ lines.push("### Unverifiable (Conceptual)");
1615
+ for (const mc of mappings.filter((c) => c.result === "unverifiable")) {
1616
+ lines.push(`- **${mc.entryId}** (${mc.entryName}): \`${mc.field}\``);
1617
+ }
1618
+ lines.push("");
1619
+ }
1620
+ if (refs.length > 0) {
1621
+ lines.push(
1622
+ "## Cross-Reference Verification",
1623
+ `- ${refs.length} references checked`,
1624
+ `- ${refsValid} valid (${refs.length > 0 ? Math.round(refsValid / refs.length * 100) : 0}%)`,
1625
+ `- ${refsBroken} broken`,
1626
+ ""
1627
+ );
1628
+ }
1629
+ if (refsBroken > 0) {
1630
+ lines.push("### Broken References");
1631
+ for (const rc of refs.filter((c) => !c.found)) {
1632
+ lines.push(`- **${rc.entryId}** (${rc.entryName}): relatedRules \`${rc.refValue}\` \u2014 entry not found`);
1633
+ }
1634
+ lines.push("");
1635
+ }
1636
+ lines.push(`## Trust Score: ${trustScore}% (${totalPassed} of ${totalChecks} checks passed)`);
1637
+ if (mode === "fix" && fixes.length > 0) {
1638
+ lines.push(
1639
+ "",
1640
+ "## Applied Fixes",
1641
+ `Updated codeMapping status from \`aligned\` \u2192 \`drifted\` on ${fixes.length} entr${fixes.length === 1 ? "y" : "ies"}:`
1642
+ );
1643
+ for (const eid of fixes) lines.push(`- ${eid}`);
1644
+ } else if (mode === "report" && drifted > 0) {
1645
+ const fixable = mappings.filter((c) => c.result === "drifted" && c.currentStatus === "aligned").length;
1646
+ if (fixable > 0) {
1647
+ lines.push("", `_${fixable} mapping(s) marked "aligned" but actually drifted. Run with mode="fix" to update._`);
1648
+ }
1649
+ }
1650
+ lines.push("", "---", `_Schema: ${schemaTableCount} tables parsed from convex/schema.ts. Project root: ${projectRoot}_`);
1651
+ return lines.join("\n");
1652
+ }
1653
+ function registerVerifyTools(server) {
1654
+ server.registerTool(
1655
+ "verify",
1656
+ {
1657
+ title: "Verify the Chain",
1658
+ description: "Verify knowledge entries against the actual codebase. Checks glossary code mappings (do referenced files and schema fields still exist?) and validates cross-references (do relatedRules point to real entries?). Produces a trust report with a trust score. Use mode='fix' to auto-update drifted codeMapping statuses.",
1659
+ inputSchema: {
1660
+ collection: z4.string().default("glossary").describe("Collection slug to verify (default: glossary)"),
1661
+ mode: z4.enum(["report", "fix"]).default("report").describe("'report' = read-only trust report. 'fix' = also update drifted codeMapping statuses.")
1662
+ },
1663
+ annotations: { readOnlyHint: false }
1664
+ },
1665
+ async ({ collection, mode }) => {
1666
+ const projectRoot = resolveProjectRoot();
1667
+ if (!projectRoot) {
1668
+ return {
1669
+ content: [{
1670
+ type: "text",
1671
+ text: "# Verification Failed\n\nCannot find project root (looked for `convex/schema.ts` in cwd and parent directory).\n\nSet `WORKSPACE_PATH` in `.env.mcp` to the absolute path of the Product Brain project root."
1672
+ }]
1673
+ };
1674
+ }
1675
+ const schema = parseConvexSchema(resolve(projectRoot, "convex/schema.ts"));
1676
+ await server.sendLoggingMessage({
1677
+ level: "info",
1678
+ data: `Verifying "${collection}" against ${schema.size} schema tables at ${projectRoot}`,
1679
+ logger: "product-os"
1680
+ });
1681
+ const scopedEntries = await mcpQuery("chain.listEntries", { collectionSlug: collection });
1682
+ if (scopedEntries.length === 0) {
1683
+ return {
1684
+ content: [{ type: "text", text: `No entries found in \`${collection}\`. Nothing to verify.` }]
1685
+ };
1686
+ }
1687
+ let allEntryIds;
1688
+ try {
1689
+ const allEntries = await mcpQuery("chain.listEntries", {});
1690
+ allEntryIds = new Set(allEntries.map((e) => e.entryId).filter(Boolean));
1691
+ } catch {
1692
+ allEntryIds = new Set(scopedEntries.map((e) => e.entryId).filter(Boolean));
1693
+ }
1694
+ const mappingChecks = [];
1695
+ const refChecks = [];
1696
+ for (const entry of scopedEntries) {
1697
+ const eid = entry.entryId ?? entry.name;
1698
+ const ename = entry.name;
1699
+ const codeMappings = entry.data?.codeMapping ?? [];
1700
+ for (const cm of codeMappings) {
1701
+ const rawField = cm.field ?? "";
1702
+ for (const rawRef of splitMultiRefs(rawField)) {
1703
+ const cleaned = cleanFieldRef(rawRef);
1704
+ if (!cleaned) continue;
1705
+ const kind = classifyRef(cleaned);
1706
+ let result;
1707
+ let reason;
1708
+ if (kind === "file") {
1709
+ ({ result, reason } = checkFileRef(cleaned, projectRoot));
1710
+ } else if (kind === "schema") {
1711
+ ({ result, reason } = checkSchemaRef(cleaned, schema));
1712
+ } else {
1713
+ result = "unverifiable";
1714
+ reason = "conceptual reference";
1715
+ }
1716
+ mappingChecks.push({
1717
+ entryId: eid,
1718
+ entryName: ename,
1719
+ field: rawRef.trim(),
1720
+ kind,
1721
+ result,
1722
+ reason,
1723
+ currentStatus: cm.status ?? "unknown"
1724
+ });
1725
+ }
1726
+ }
1727
+ const MAX_CROSS_REFS = 50;
1728
+ const relatedRules = entry.data?.relatedRules ?? [];
1729
+ for (const ruleId of relatedRules) {
1730
+ if (refChecks.length >= MAX_CROSS_REFS) break;
1731
+ refChecks.push({
1732
+ entryId: eid,
1733
+ entryName: ename,
1734
+ refValue: ruleId,
1735
+ found: allEntryIds.has(ruleId)
1736
+ });
1737
+ }
1738
+ }
1739
+ const fixes = [];
1740
+ if (mode === "fix") {
1741
+ const driftedByEntry = /* @__PURE__ */ new Map();
1742
+ for (const mc of mappingChecks) {
1743
+ if (mc.result === "drifted" && mc.currentStatus === "aligned") {
1744
+ if (!driftedByEntry.has(mc.entryId)) driftedByEntry.set(mc.entryId, /* @__PURE__ */ new Set());
1745
+ driftedByEntry.get(mc.entryId).add(mc.field);
1746
+ }
1747
+ }
1748
+ for (const [eid, driftedFields] of driftedByEntry) {
1749
+ const entry = scopedEntries.find((e) => (e.entryId ?? e.name) === eid);
1750
+ if (!entry?.entryId) continue;
1751
+ const updated = (entry.data?.codeMapping ?? []).map(
1752
+ (cm) => cm.status === "aligned" && driftedFields.has(cm.field) ? { ...cm, status: "drifted" } : cm
1753
+ );
1754
+ await mcpMutation("chain.updateEntry", {
1755
+ entryId: entry.entryId,
1756
+ data: { codeMapping: updated }
1757
+ });
1758
+ fixes.push(entry.entryId);
1759
+ }
1760
+ }
1761
+ const report = formatTrustReport(
1762
+ collection,
1763
+ scopedEntries.length,
1764
+ mappingChecks,
1765
+ refChecks,
1766
+ fixes,
1767
+ mode,
1768
+ schema.size,
1769
+ projectRoot
1770
+ );
1771
+ return { content: [{ type: "text", text: report }] };
1772
+ }
1773
+ );
1774
+ }
1775
+
1776
+ // src/tools/architecture.ts
1777
+ import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, statSync } from "fs";
1778
+ import { resolve as resolve2, relative, dirname, normalize } from "path";
1779
+ import { z as z5 } from "zod";
1780
+ var COLLECTION_SLUG = "architecture";
1781
+ var COLLECTION_FIELDS = [
1782
+ { key: "archType", label: "Architecture Type", type: "select", required: true, options: ["template", "layer", "node", "flow"], searchable: true },
1783
+ { key: "templateRef", label: "Template Entry ID", type: "text", searchable: false },
1784
+ { key: "layerRef", label: "Layer Entry ID", type: "text", searchable: false },
1785
+ { key: "description", label: "Description", type: "text", searchable: true },
1786
+ { key: "color", label: "Color", type: "text" },
1787
+ { key: "icon", label: "Icon", type: "text" },
1788
+ { key: "sourceNode", label: "Source Node (flows)", type: "text" },
1789
+ { key: "targetNode", label: "Target Node (flows)", type: "text" },
1790
+ { key: "filePaths", label: "File Paths", type: "text", searchable: true },
1791
+ { key: "owner", label: "Owner (circle/role)", type: "text", searchable: true },
1792
+ { key: "layerOrder", label: "Layer Order (for templates)", type: "text" },
1793
+ { key: "rationale", label: "Why Here? (placement rationale)", type: "text", searchable: true },
1794
+ { key: "dependsOn", label: "Allowed Dependencies (layers this can import from)", type: "text" }
1795
+ ];
1796
+ var ARCHITECTURE_CANONICAL_KEY = "architecture_note";
1797
+ async function ensureCollection() {
1798
+ const collections = await mcpQuery("chain.listCollections");
1799
+ const existing = collections.find((c) => c.slug === COLLECTION_SLUG);
1800
+ if (existing) {
1801
+ if (!existing.defaultCanonicalKey) {
1802
+ await mcpMutation("chain.updateCollection", {
1803
+ slug: COLLECTION_SLUG,
1804
+ defaultCanonicalKey: ARCHITECTURE_CANONICAL_KEY
1805
+ });
1806
+ }
1807
+ return;
1808
+ }
1809
+ await mcpMutation("chain.createCollection", {
1810
+ slug: COLLECTION_SLUG,
1811
+ name: "Architecture",
1812
+ icon: "\u{1F3D7}\uFE0F",
1813
+ description: "System architecture map \u2014 templates, layers, nodes, and flows. Visualized in the Architecture Explorer and via MCP architecture tools.",
1814
+ fields: COLLECTION_FIELDS
1815
+ });
1816
+ }
1817
+ async function listArchEntries() {
1818
+ return mcpQuery("chain.listEntries", { collectionSlug: COLLECTION_SLUG });
1819
+ }
1820
+ function byTag(entries, archType) {
1821
+ return entries.filter((e) => e.tags?.includes(`archType:${archType}`) || e.data?.archType === archType).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1822
+ }
1823
+ function renderArchitectureHtml(layers, nodes, flows, templateName) {
1824
+ const layerHtml = layers.map((layer) => {
1825
+ const layerNodes = nodes.filter(
1826
+ (n) => n.data?.layerRef === layer.entryId
1827
+ );
1828
+ const nodeCards = layerNodes.map((n) => `
1829
+ <div class="node" title="${escHtml(String(n.data?.description ?? ""))}">
1830
+ <span class="node-icon">${escHtml(String(n.data?.icon ?? "\u25FB"))}</span>
1831
+ <span class="node-name">${escHtml(n.name)}</span>
1832
+ </div>
1833
+ `).join("");
1834
+ return `
1835
+ <div class="layer" style="--layer-color: ${escHtml(String(layer.data?.color ?? "#666"))}">
1836
+ <div class="layer-label">
1837
+ <span class="layer-dot"></span>
1838
+ <span class="layer-name">${escHtml(layer.name)}</span>
1839
+ <span class="layer-count">${layerNodes.length}</span>
1840
+ </div>
1841
+ <div class="layer-desc">${escHtml(String(layer.data?.description ?? ""))}</div>
1842
+ <div class="nodes">${nodeCards || '<span class="empty">No components</span>'}</div>
1843
+ </div>
1844
+ `;
1845
+ }).join("");
1846
+ return `<!DOCTYPE html>
1847
+ <html><head><meta charset="utf-8"><style>
1848
+ *{margin:0;padding:0;box-sizing:border-box}
1849
+ body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;padding:16px}
1850
+ h1{font-size:14px;font-weight:600;color:#a0a0c0;margin-bottom:12px;letter-spacing:.04em}
1851
+ .layer{border-left:3px solid var(--layer-color);padding:8px 12px;margin-bottom:8px;background:rgba(255,255,255,.03);border-radius:0 6px 6px 0}
1852
+ .layer-label{display:flex;align-items:center;gap:8px;margin-bottom:4px}
1853
+ .layer-dot{width:8px;height:8px;border-radius:50%;background:var(--layer-color)}
1854
+ .layer-name{font-size:12px;font-weight:600;color:#fff;letter-spacing:.03em}
1855
+ .layer-count{font-size:10px;color:var(--layer-color);background:rgba(255,255,255,.06);padding:1px 6px;border-radius:8px}
1856
+ .layer-desc{font-size:11px;color:#888;margin-bottom:6px}
1857
+ .nodes{display:flex;flex-wrap:wrap;gap:6px}
1858
+ .node{display:flex;align-items:center;gap:4px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:4px;padding:4px 8px;font-size:11px;cursor:default;transition:border-color .15s}
1859
+ .node:hover{border-color:var(--layer-color)}
1860
+ .node-icon{font-size:12px}
1861
+ .node-name{color:#ddd}
1862
+ .empty{font-size:11px;color:#555;font-style:italic}
1863
+ .flows{margin-top:12px;border-top:1px solid rgba(255,255,255,.06);padding-top:8px}
1864
+ .flow{font-size:11px;color:#888;padding:2px 0}
1865
+ .flow-arrow{color:#6366f1;margin:0 4px}
1866
+ </style></head><body>
1867
+ <h1>${escHtml(templateName)}</h1>
1868
+ ${layerHtml}
1869
+ ${flows.length > 0 ? `<div class="flows"><div style="font-size:10px;color:#666;margin-bottom:4px;letter-spacing:.06em">DATA FLOWS</div>${flows.map((f) => `<div class="flow">${escHtml(f.name)}<span class="flow-arrow">\u2192</span><span style="color:#aaa">${escHtml(String(f.data?.description ?? ""))}</span></div>`).join("")}</div>` : ""}
1870
+ </body></html>`;
1871
+ }
1872
+ function escHtml(s) {
1873
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1874
+ }
1875
+ function formatLayerText(layer, nodes) {
1876
+ const layerNodes = nodes.filter((n) => n.data?.layerRef === layer.entryId);
1877
+ const nodeList = layerNodes.map((n) => {
1878
+ const desc = n.data?.description ? ` \u2014 ${n.data.description}` : "";
1879
+ const owner = n.data?.owner ? ` (${n.data.owner})` : "";
1880
+ return ` - ${n.data?.icon ?? "\u25FB"} **${n.name}**${desc}${owner}`;
1881
+ }).join("\n");
1882
+ return `### ${layer.name}
1883
+ ${layer.data?.description ?? ""}
1884
+
1885
+ ${nodeList || " _No components_"}`;
1886
+ }
1887
+ var SEED_TEMPLATE = {
1888
+ entryId: "ARCH-tpl-product-os",
1889
+ name: "Product OS Default",
1890
+ data: {
1891
+ archType: "template",
1892
+ description: "Default 4-layer architecture: Auth \u2192 Infrastructure \u2192 Core \u2192 Features, with an outward Integration layer",
1893
+ layerOrder: JSON.stringify(["auth", "infrastructure", "core", "features", "integration"])
1894
+ }
1895
+ };
1896
+ var SEED_LAYERS = [
1897
+ { entryId: "ARCH-layer-auth", name: "Auth", order: 0, data: { archType: "layer", templateRef: "ARCH-tpl-product-os", color: "#22c55e", description: "Authentication, user management, and workspace-scoped access control", icon: "\u{1F510}", dependsOn: "none", rationale: "Foundation layer. Auth depends on nothing \u2014 it is the first gate. No layer may bypass auth. All other layers depend on auth to know who the user is and which workspace they belong to." } },
1898
+ { entryId: "ARCH-layer-infra", name: "Infrastructure", order: 1, data: { archType: "layer", templateRef: "ARCH-tpl-product-os", color: "#ec4899", description: "Real-time data, event tracking, and AI model infrastructure", icon: "\u2699\uFE0F", dependsOn: "Auth", rationale: "Infrastructure sits on top of Auth. It provides the database, analytics, and AI plumbing that Core and Features consume. Infra may import from Auth (needs workspace context) but never from Core or Features." } },
1899
+ { entryId: "ARCH-layer-core", name: "Core", order: 2, data: { archType: "layer", templateRef: "ARCH-tpl-product-os", color: "#8b5cf6", description: "Business logic, knowledge graph, workflow engines, and MCP tooling", icon: "\u{1F9E0}", dependsOn: "Auth, Infrastructure", rationale: "Core contains business logic and engines that are UI-agnostic. It may import from Auth and Infra. Features depend on Core, but Core must never depend on Features \u2014 this is what keeps engines reusable across different UIs." } },
1900
+ { entryId: "ARCH-layer-features", name: "Features", order: 3, data: { archType: "layer", templateRef: "ARCH-tpl-product-os", color: "#6366f1", description: "User-facing pages, components, and feature modules", icon: "\u2726", dependsOn: "Auth, Infrastructure, Core", rationale: "Features are the user-facing layer \u2014 SvelteKit routes, components, and page-level logic. Features may import from any lower layer but nothing above may import from Features. This is the outermost application layer." } },
1901
+ { entryId: "ARCH-layer-integration", name: "Integration", order: 4, data: { archType: "layer", templateRef: "ARCH-tpl-product-os", color: "#f59e0b", description: "Outward connections to external tools, IDEs, and services", icon: "\u{1F50C}", dependsOn: "Core", rationale: "Integration is a lateral/outward layer \u2014 it connects to external systems (IDE, GitHub, Linear). It depends on Core (to expose knowledge) but sits outside the main stack. External tools call into Core via Integration, never directly into Features." } }
1902
+ ];
1903
+ var SEED_NODES = [
1904
+ // Auth layer
1905
+ { entryId: "ARCH-node-clerk", name: "Clerk", order: 0, data: { archType: "node", layerRef: "ARCH-layer-auth", color: "#22c55e", icon: "\u{1F511}", description: "Authentication provider \u2014 sign-in/sign-up pages, session management, organization-level access control. UserSync.svelte is cross-cutting layout glue (not mapped here).", filePaths: "src/routes/sign-in/, src/routes/sign-up/", owner: "Platform", rationale: "Auth layer because Clerk is the identity gate. Every request flows through auth first. No other layer provides identity." } },
1906
+ { entryId: "ARCH-node-workspace", name: "Workspace Scoping", order: 1, data: { archType: "node", layerRef: "ARCH-layer-auth", color: "#22c55e", icon: "\u{1F3E2}", description: "Multi-tenancy anchor \u2014 all data is workspace-scoped via workspaceId", filePaths: "src/lib/stores/workspace.ts, convex/workspaces.ts", owner: "Platform", rationale: "Auth layer because workspace scoping is the second gate after identity. All queries require workspaceId, making this foundational." } },
1907
+ // Infrastructure layer
1908
+ { entryId: "ARCH-node-convex", name: "Convex", order: 0, data: { archType: "node", layerRef: "ARCH-layer-infra", color: "#ec4899", icon: "\u26A1", description: "Reactive database with real-time sync, serverless functions, and type-safe API generation. Unified Collections + Entries model", filePaths: "convex/schema.ts, convex/entries.ts, convex/http.ts", owner: "Platform", rationale: "Infrastructure because Convex is raw persistence and reactivity plumbing. It stores data but has no business logic opinions. Core and Features consume it." } },
1909
+ { entryId: "ARCH-node-posthog", name: "PostHog", order: 1, data: { archType: "node", layerRef: "ARCH-layer-infra", color: "#ec4899", icon: "\u{1F4CA}", description: "Product analytics \u2014 workspace-scoped events, feature flags, session replay", filePaths: "src/lib/analytics.ts, src/lib/components/PostHogWorkspaceSync.svelte", owner: "Platform", rationale: "Infrastructure because PostHog is analytics plumbing \u2014 event collection and aggregation. It has no knowledge of business domains." } },
1910
+ { entryId: "ARCH-node-openrouter", name: "OpenRouter", order: 2, data: { archType: "node", layerRef: "ARCH-layer-infra", color: "#ec4899", icon: "\u{1F916}", description: "AI model routing for ChainWork artifact generation \u2014 streaming responses with format-aware prompts", filePaths: "src/routes/api/chainwork/generate/+server.ts", owner: "ChainWork", rationale: "Infrastructure because OpenRouter is an AI model gateway \u2014 it routes prompts to models. The strategy logic lives in ChainWork Engine (Core); this is just the pipe." } },
1911
+ // Core layer
1912
+ { entryId: "ARCH-node-mcp", name: "MCP Server", order: 0, data: { archType: "node", layerRef: "ARCH-layer-core", color: "#8b5cf6", icon: "\u{1F527}", description: "~19 modular tools exposing the knowledge graph as AI-consumable operations \u2014 capture, context assembly, verification, quality checks", filePaths: "packages/mcp-server/src/index.ts, packages/mcp-server/src/tools/", owner: "AI DX", rationale: "Core layer because the MCP server encodes business operations \u2014 capture with auto-linking, quality scoring, governance rules. It depends on Infra (Convex) but never touches UI routes. Why not Infrastructure? Because it has domain opinions. Why not Features? Because it has no UI \u2014 any client (Cursor, CLI, API) can call it." } },
1913
+ { entryId: "ARCH-node-knowledge-graph", name: "Knowledge Graph", order: 1, data: { archType: "node", layerRef: "ARCH-layer-core", color: "#8b5cf6", icon: "\u{1F578}\uFE0F", description: "20 collections, 170+ entries with typed cross-collection relations. Smart capture, auto-linking, quality scoring", filePaths: "convex/mcpKnowledge.ts, convex/entries.ts", owner: "Knowledge", rationale: "Core layer because the knowledge graph IS the domain model \u2014 collections, entries, relations, versioning, quality scoring. Glossary DATA, business rules DATA, tension DATA all live here. Why not Features? Because the data model exists independently of any page. The Glossary page in Features is just one way to visualize terms that Core owns. Think: Core owns the dictionary, Features owns the dictionary app." } },
1914
+ { entryId: "ARCH-node-governance", name: "Governance Engine", order: 2, data: { archType: "node", layerRef: "ARCH-layer-core", color: "#8b5cf6", icon: "\u2696\uFE0F", description: "Circles, roles, consent-based decision-making, tension processing with IDM-inspired async workflows", filePaths: "convex/versioning.ts, src/lib/components/versioning/", owner: "Governance", rationale: "Core layer because governance logic (draft\u2192publish workflows, consent-based decisions, tension status rules) is business process that multiple UIs consume. Why not Features? Because the versioning system and proposal flow are reusable engines \u2014 the Governance Pages in Features are just one rendering of these rules." } },
1915
+ { entryId: "ARCH-node-chainwork-engine", name: "ChainWork Engine", order: 3, data: { archType: "node", layerRef: "ARCH-layer-core", color: "#8b5cf6", icon: "\u26D3", description: "Guided strategy creation through 5-step coherence chain \u2014 AI-generated artifacts with scoring and achievements", filePaths: "src/lib/components/chainwork/config.ts, src/lib/components/chainwork/scoring.ts", owner: "ChainWork", rationale: "Core layer because the coherence chain logic, scoring algorithm, and quality gates are business rules. config.ts defines chain steps, scoring.ts computes quality \u2014 these could power a CLI or API. Why not Features? Because the ChainWork UI wizard in Features is just one skin over this engine." } },
1916
+ // Features layer
1917
+ { entryId: "ARCH-node-command-center", name: "Command Center", order: 0, data: { archType: "node", layerRef: "ARCH-layer-features", color: "#6366f1", icon: "\u2B21", description: "Calm home screen \u2014 daily orientation, triage mode, pulse metrics, momentum tracking", filePaths: "src/routes/+page.svelte, src/lib/components/command-center/", owner: "Command Center", rationale: "Features layer because the Command Center is a SvelteKit page \u2014 PulseMetrics, NeedsAttention, DailyBriefing are UI components that assemble data from Core queries. Why not Core? Because it has no reusable business logic or engines \u2014 it is pure layout and presentation. If you deleted this page, no business rule would break." } },
1918
+ { entryId: "ARCH-node-chainwork-ui", name: "ChainWork UI", order: 1, data: { archType: "node", layerRef: "ARCH-layer-features", color: "#6366f1", icon: "\u26D3", description: "Multi-step wizard for strategy artifact creation \u2014 setup, chain steps, quality gates, output", filePaths: "src/routes/chainwork/, src/lib/components/chainwork/", owner: "ChainWork", rationale: "Features layer because the ChainWork UI is the wizard interface \u2014 step navigation, form inputs, output rendering. Why not Core? Because the scoring logic and chain config ARE in Core (ChainWork Engine). This is the presentation skin over that engine. Delete this page and the engine still works via MCP." } },
1919
+ { entryId: "ARCH-node-glossary", name: "Glossary", order: 2, data: { archType: "node", layerRef: "ARCH-layer-features", color: "#6366f1", icon: "Aa", description: "Canonical vocabulary management \u2014 term detail, code drift detection, inline term linking", filePaths: "src/routes/glossary/, src/lib/components/glossary/", owner: "Knowledge", rationale: "Features layer \u2014 NOT Core or Infrastructure \u2014 because this is the Glossary PAGE: the SvelteKit route for browsing, editing, and viewing terms. Why not Core? Because Core owns the glossary DATA (Knowledge Graph) and term-linking logic. The MCP server also accesses glossary terms without any page. Why not Infrastructure? Because glossary is domain-specific vocabulary, not generic plumbing. The page is one consumer of the data \u2014 Core owns the dictionary, Features owns the dictionary app." } },
1920
+ { entryId: "ARCH-node-tensions", name: "Tensions", order: 3, data: { archType: "node", layerRef: "ARCH-layer-features", color: "#6366f1", icon: "\u26A1", description: "Tension capture and processing \u2014 raise, triage, resolve through governance workflow", filePaths: "src/routes/tensions/, src/lib/components/tensions/", owner: "Governance", rationale: "Features layer because this is the Tensions PAGE \u2014 the UI for raising, listing, and viewing tensions. Why not Core? Because tension data, status rules (SOS-020), and processing logic already live in Core (Governance Engine). This page is a form + list view that reads from and writes to Core. Delete it and the governance engine still processes tensions via MCP." } },
1921
+ { entryId: "ARCH-node-strategy", name: "Strategy", order: 4, data: { archType: "node", layerRef: "ARCH-layer-features", color: "#6366f1", icon: "\u25A3", description: "Product strategy pages \u2014 vision, ecosystem, product areas, decision frameworks, sequencing", filePaths: "src/routes/strategy/, src/routes/bridge/, src/routes/topology/", owner: "Strategy", rationale: "Features layer because Strategy is a set of SvelteKit pages (strategy, bridge, topology) that visualize strategy entries. Why not Core? Because the strategy data (vision, principles, ecosystem layers) lives in the Knowledge Graph (Core). These pages render and allow inline editing \u2014 they consume Core downward." } },
1922
+ { entryId: "ARCH-node-governance-ui", name: "Governance Pages", order: 5, data: { archType: "node", layerRef: "ARCH-layer-features", color: "#6366f1", icon: "\u25C8", description: "Roles, circles, principles, policies, decisions, proposals, business rules", filePaths: "src/routes/roles/, src/routes/circles/, src/routes/decisions/, src/routes/proposals/", owner: "Governance", rationale: "Features layer because these are governance PAGES \u2014 list views and detail views for roles, circles, decisions, proposals. Why not Core? Because the governance ENGINE (versioning, consent, IDM) IS in Core. These pages are the user-facing window into governance data. The logic doesn't live here, only the rendering." } },
1923
+ { entryId: "ARCH-node-artifacts", name: "Artifacts", order: 6, data: { archType: "node", layerRef: "ARCH-layer-features", color: "#6366f1", icon: "\u{1F4C4}", description: "Strategy artifacts from ChainWork \u2014 pitches, briefs, one-pagers linked to the knowledge graph", filePaths: "src/routes/artifacts/", owner: "ChainWork", rationale: "Features layer because the Artifacts page is a list/detail view for strategy artifacts produced by ChainWork. Why not Core? Because artifact data and scoring live in Core (ChainWork Engine). This page just renders the output and links back to the knowledge graph." } },
1924
+ // Integration layer
1925
+ { entryId: "ARCH-node-cursor", name: "Cursor IDE", order: 0, data: { archType: "node", layerRef: "ARCH-layer-integration", color: "#f59e0b", icon: "\u{1F5A5}\uFE0F", description: "AI-assisted development with MCP-powered knowledge context \u2014 smart capture, verification, context assembly in the editor", filePaths: ".cursor/mcp.json, .cursor/rules/", owner: "AI DX", rationale: "Integration layer because Cursor is an external tool that connects INTO our system via MCP. Why not Core or Features? Because Cursor itself is not our code \u2014 it's a consumer. The .cursor/ config files define how it talks to us, but Cursor lives outside our deployment boundary." } },
1926
+ { entryId: "ARCH-node-github", name: "GitHub", order: 1, data: { archType: "node", layerRef: "ARCH-layer-integration", color: "#f59e0b", icon: "\u{1F419}", description: "Code repository, PR reviews with governance context, CI/CD", owner: "Platform", rationale: "Integration layer because GitHub is an external service. Why not Infrastructure? Because Infra is about plumbing we control (database, analytics). GitHub is a third-party that hooks into our Core (PR reviews checking governance rules) but is not part of our deployed application." } },
1927
+ { entryId: "ARCH-node-linear", name: "Linear (planned)", order: 2, data: { archType: "node", layerRef: "ARCH-layer-integration", color: "#f59e0b", icon: "\u{1F4D0}", description: "Issue tracking, roadmap sync, tension-to-issue pipeline (planned integration)", owner: "Platform", rationale: "Integration layer because Linear is an external issue tracker. Why not Infrastructure? Because Infra is generic plumbing we run. Linear is a third-party tool we connect to. The planned pipeline bridges tensions (Core) to Linear issues \u2014 a classic outward integration pattern." } }
1928
+ ];
1929
+ var SEED_FLOWS = [
1930
+ { entryId: "ARCH-flow-smart-capture", name: "Capture Flow", order: 0, data: { archType: "flow", sourceNode: "ARCH-node-cursor", targetNode: "ARCH-node-knowledge-graph", description: "Developer/AI calls capture via MCP \u2192 entry created with auto-linking and quality score \u2192 stored in Knowledge Graph", color: "#8b5cf6" } },
1931
+ { entryId: "ARCH-flow-governance", name: "Governance Flow", order: 1, data: { archType: "flow", sourceNode: "ARCH-node-tensions", targetNode: "ARCH-node-governance", description: "Tension raised \u2192 appears in Command Center \u2192 triaged \u2192 processed via IDM \u2192 decision logged", color: "#6366f1" } },
1932
+ { entryId: "ARCH-flow-chainwork", name: "ChainWork Strategy Flow", order: 2, data: { archType: "flow", sourceNode: "ARCH-node-chainwork-ui", targetNode: "ARCH-node-artifacts", description: "Leader opens ChainWork \u2192 walks coherence chain \u2192 AI generates artifact \u2192 scored and published to knowledge graph", color: "#f59e0b" } },
1933
+ { entryId: "ARCH-flow-knowledge-trust", name: "Knowledge Trust Flow", order: 3, data: { archType: "flow", sourceNode: "ARCH-node-mcp", targetNode: "ARCH-node-glossary", description: "MCP verify tool checks entries against codebase \u2192 file existence, schema references validated \u2192 trust scores updated", color: "#22c55e" } },
1934
+ { entryId: "ARCH-flow-analytics", name: "Analytics Flow", order: 4, data: { archType: "flow", sourceNode: "ARCH-node-command-center", targetNode: "ARCH-node-posthog", description: "Feature views and actions tracked \u2192 workspace-scoped events \u2192 PostHog group analytics \u2192 Command Center metrics", color: "#ec4899" } }
1935
+ ];
1936
+ function registerArchitectureTools(server) {
1937
+ server.registerTool(
1938
+ "architecture",
1939
+ {
1940
+ title: "Architecture",
1941
+ description: "Explore the system architecture \u2014 show the full map, explore a specific layer, or visualize a data flow.\n\nActions:\n- `show`: Render the layered architecture map (Auth \u2192 Infra \u2192 Core \u2192 Features \u2192 Integration)\n- `explore`: Drill into a layer to see nodes, ownership, file paths\n- `flow`: Visualize a data flow path between nodes",
1942
+ inputSchema: {
1943
+ action: z5.enum(["show", "explore", "flow"]).describe("Action: show full map, explore a layer, or visualize a flow"),
1944
+ template: z5.string().optional().describe("Template entry ID to filter by (for show)"),
1945
+ layer: z5.string().optional().describe("Layer name or entry ID (for explore), e.g. 'Core' or 'ARCH-layer-core'"),
1946
+ flow: z5.string().optional().describe("Flow name or entry ID (for flow), e.g. 'Smart Capture Flow'")
1947
+ },
1948
+ annotations: { readOnlyHint: true }
1949
+ },
1950
+ async ({ action, template, layer, flow }) => {
1951
+ await ensureCollection();
1952
+ const all = await listArchEntries();
1953
+ if (action === "show") {
1954
+ const templates = byTag(all, "template");
1955
+ const activeTemplate = template ? templates.find((t) => t.entryId === template) : templates[0];
1956
+ const templateName = activeTemplate?.name ?? "System Architecture";
1957
+ const templateId = activeTemplate?.entryId;
1958
+ const layers = byTag(all, "layer").filter((l) => !templateId || l.data?.templateRef === templateId);
1959
+ const nodes = byTag(all, "node");
1960
+ const flows = byTag(all, "flow");
1961
+ if (layers.length === 0) {
1962
+ return {
1963
+ content: [{
1964
+ type: "text",
1965
+ text: "# Architecture Explorer\n\nNo architecture data found. Use `architecture-admin action=seed` to populate the default architecture."
1966
+ }]
1967
+ };
1968
+ }
1969
+ const textLayers = layers.map((l) => formatLayerText(l, nodes)).join("\n\n");
1970
+ const textFlows = flows.length > 0 ? "\n\n---\n\n## Data Flows\n\n" + flows.map(
1971
+ (f) => `- **${f.name}**: ${f.data?.description ?? ""}`
1972
+ ).join("\n") : "";
1973
+ const text = `# ${templateName}
1974
+
1975
+ ${textLayers}${textFlows}`;
1976
+ const html = renderArchitectureHtml(layers, nodes, flows, templateName);
1977
+ return {
1978
+ content: [
1979
+ { type: "text", text },
1980
+ { type: "resource", resource: { uri: `ui://product-os/architecture`, mimeType: "text/html", text: html } }
1981
+ ]
1982
+ };
1983
+ }
1984
+ if (action === "explore") {
1985
+ if (!layer) return { content: [{ type: "text", text: "A `layer` is required for explore." }] };
1986
+ const layers = byTag(all, "layer");
1987
+ const target = layers.find(
1988
+ (l) => l.name.toLowerCase() === layer.toLowerCase() || l.entryId === layer
1989
+ );
1990
+ if (!target) {
1991
+ const available = layers.map((l) => `\`${l.name}\``).join(", ");
1992
+ return { content: [{ type: "text", text: `Layer "${layer}" not found. Available layers: ${available}` }] };
1993
+ }
1994
+ const nodes = byTag(all, "node").filter((n) => n.data?.layerRef === target.entryId);
1995
+ const flows = byTag(all, "flow").filter(
1996
+ (f) => nodes.some((n) => n.entryId === f.data?.sourceNode || n.entryId === f.data?.targetNode)
1997
+ );
1998
+ const depRule = target.data?.dependsOn ? `
1999
+ **Dependency rule:** May import from ${target.data.dependsOn === "none" ? "nothing (foundation layer)" : target.data.dependsOn}.
2000
+ ` : "";
2001
+ const layerRationale = target.data?.rationale ? `
2002
+ > ${target.data.rationale}
2003
+ ` : "";
2004
+ const nodeDetail = nodes.map((n) => {
2005
+ const lines = [`#### ${n.data?.icon ?? "\u25FB"} ${n.name}`];
2006
+ if (n.data?.description) lines.push(String(n.data.description));
2007
+ if (n.data?.owner) lines.push(`**Owner:** ${n.data.owner}`);
2008
+ if (n.data?.filePaths) lines.push(`**Files:** \`${n.data.filePaths}\``);
2009
+ if (n.data?.rationale) lines.push(`
2010
+ **Why here?** ${n.data.rationale}`);
2011
+ return lines.join("\n");
2012
+ }).join("\n\n");
2013
+ const flowLines = flows.length > 0 ? "\n\n### Connected Flows\n\n" + flows.map((f) => `- ${f.name}: ${f.data?.description ?? ""}`).join("\n") : "";
2014
+ return {
2015
+ content: [{
2016
+ type: "text",
2017
+ text: `# ${target.data?.icon ?? ""} ${target.name} Layer
2018
+
2019
+ ${target.data?.description ?? ""}${depRule}${layerRationale}
2020
+ **${nodes.length} components**
2021
+
2022
+ ${nodeDetail}${flowLines}`
2023
+ }]
2024
+ };
2025
+ }
2026
+ if (action === "flow") {
2027
+ if (!flow) return { content: [{ type: "text", text: "A `flow` name or entry ID is required." }] };
2028
+ const flows = byTag(all, "flow");
2029
+ const target = flows.find(
2030
+ (f) => f.name.toLowerCase() === flow.toLowerCase() || f.entryId === flow
2031
+ );
2032
+ if (!target) {
2033
+ const available = flows.map((f) => `\`${f.name}\``).join(", ");
2034
+ return { content: [{ type: "text", text: `Flow "${flow}" not found. Available flows: ${available}` }] };
2035
+ }
2036
+ const nodes = byTag(all, "node");
2037
+ const source = nodes.find((n) => n.entryId === target.data?.sourceNode);
2038
+ const dest = nodes.find((n) => n.entryId === target.data?.targetNode);
2039
+ const lines = [
2040
+ `# ${target.name}`,
2041
+ "",
2042
+ `**${source?.data?.icon ?? "?"} ${source?.name ?? "Unknown"}** \u2192 **${dest?.data?.icon ?? "?"} ${dest?.name ?? "Unknown"}**`,
2043
+ "",
2044
+ String(target.data?.description ?? "")
2045
+ ];
2046
+ if (source) {
2047
+ lines.push("", `### Source: ${source.name}`, String(source.data?.description ?? ""));
2048
+ if (source.data?.filePaths) lines.push(`Files: \`${source.data.filePaths}\``);
2049
+ }
2050
+ if (dest) {
2051
+ lines.push("", `### Target: ${dest.name}`, String(dest.data?.description ?? ""));
2052
+ if (dest.data?.filePaths) lines.push(`Files: \`${dest.data.filePaths}\``);
2053
+ }
2054
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2055
+ }
2056
+ return { content: [{ type: "text", text: "Unknown action." }] };
2057
+ }
2058
+ );
2059
+ server.registerTool(
2060
+ "architecture-admin",
2061
+ {
2062
+ title: "Architecture Admin",
2063
+ description: "Architecture maintenance \u2014 seed the default architecture data or run a dependency health check.\n\nActions:\n- `seed`: Populate the architecture collection with the default Product OS map. Safe to re-run.\n- `check`: Scan the codebase for dependency direction violations against layer rules.",
2064
+ inputSchema: {
2065
+ action: z5.enum(["seed", "check"]).describe("Action: seed default architecture data, or check dependency health")
2066
+ }
2067
+ },
2068
+ async ({ action }) => {
2069
+ if (action === "seed") {
2070
+ await ensureCollection();
2071
+ const existing = await listArchEntries();
2072
+ const existingIds = new Set(existing.map((e) => e.entryId));
2073
+ let created = 0;
2074
+ let updated = 0;
2075
+ let unchanged = 0;
2076
+ const allSeeds = [
2077
+ { ...SEED_TEMPLATE, order: 0, status: "active" },
2078
+ ...SEED_LAYERS.map((l) => ({ ...l, status: "active" })),
2079
+ ...SEED_NODES.map((n) => ({ ...n, status: "active" })),
2080
+ ...SEED_FLOWS.map((f) => ({ ...f, status: "active" }))
2081
+ ];
2082
+ for (const seed of allSeeds) {
2083
+ if (existingIds.has(seed.entryId)) {
2084
+ const existingEntry = existing.find((e) => e.entryId === seed.entryId);
2085
+ const existingData = existingEntry?.data ?? {};
2086
+ const seedData = seed.data;
2087
+ const hasChanges = Object.keys(seedData).some(
2088
+ (k) => seedData[k] !== void 0 && existingData[k] !== seedData[k]
2089
+ );
2090
+ if (hasChanges) {
2091
+ const mergedData = { ...existingData, ...seedData };
2092
+ await mcpMutation("chain.updateEntry", { entryId: seed.entryId, data: mergedData });
2093
+ updated++;
2094
+ } else {
2095
+ unchanged++;
2096
+ }
2097
+ continue;
2098
+ }
2099
+ await mcpMutation("chain.createEntry", {
2100
+ collectionSlug: COLLECTION_SLUG,
2101
+ entryId: seed.entryId,
2102
+ name: seed.name,
2103
+ status: seed.status,
2104
+ data: seed.data,
2105
+ order: seed.order ?? 0
2106
+ });
2107
+ created++;
2108
+ }
2109
+ return {
2110
+ content: [{
2111
+ type: "text",
2112
+ text: `# Architecture Seeded
2113
+
2114
+ **Created:** ${created} entries
2115
+ **Updated:** ${updated} (merged new fields)
2116
+ **Unchanged:** ${unchanged}
2117
+
2118
+ Use \`architecture action=show\` to view the map.`
2119
+ }]
2120
+ };
2121
+ }
2122
+ if (action === "check") {
2123
+ const projectRoot = resolveProjectRoot2();
2124
+ if (!projectRoot) {
2125
+ return {
2126
+ content: [{
2127
+ type: "text",
2128
+ text: "# Scan Failed\n\nCannot find project root (looked for `convex/schema.ts` in cwd and parent). Set `WORKSPACE_PATH` env var to the absolute path of the Product OS project root."
2129
+ }]
2130
+ };
2131
+ }
2132
+ await ensureCollection();
2133
+ const all = await listArchEntries();
2134
+ const layers = byTag(all, "layer");
2135
+ const nodes = byTag(all, "node");
2136
+ const result = scanDependencies(projectRoot, layers, nodes);
2137
+ return { content: [{ type: "text", text: formatScanReport(result) }] };
2138
+ }
2139
+ return { content: [{ type: "text", text: "Unknown action." }] };
2140
+ }
2141
+ );
2142
+ }
2143
+ function resolveProjectRoot2() {
2144
+ const candidates = [
2145
+ process.env.WORKSPACE_PATH,
2146
+ process.cwd(),
2147
+ resolve2(process.cwd(), "..")
2148
+ ].filter(Boolean);
2149
+ for (const dir of candidates) {
2150
+ const resolved = resolve2(dir);
2151
+ if (existsSync2(resolve2(resolved, "convex/schema.ts"))) return resolved;
2152
+ }
2153
+ return null;
2154
+ }
2155
+ function scanDependencies(projectRoot, layers, nodes) {
2156
+ const layerMap = /* @__PURE__ */ new Map();
2157
+ for (const l of layers) layerMap.set(l.entryId, l);
2158
+ const allowedDeps = buildAllowedDeps(layers);
2159
+ const nodePathPrefixes = buildNodePrefixes(nodes);
2160
+ const violations = [];
2161
+ const nodeResults = /* @__PURE__ */ new Map();
2162
+ let totalFiles = 0;
2163
+ let totalImports = 0;
2164
+ let unmapped = 0;
2165
+ for (const node of nodes) {
2166
+ const layerRef = String(node.data?.layerRef ?? "");
2167
+ const layer = layerMap.get(layerRef);
2168
+ if (!layer) continue;
2169
+ const filePaths = parseFilePaths(node);
2170
+ const nodeViolations = [];
2171
+ let nodeFileCount = 0;
2172
+ for (const fp of filePaths) {
2173
+ const absPath = resolve2(projectRoot, fp);
2174
+ const files = collectFiles(absPath);
2175
+ for (const file of files) {
2176
+ nodeFileCount++;
2177
+ totalFiles++;
2178
+ const relFile = relative(projectRoot, file);
2179
+ const imports = parseImports(file);
2180
+ for (const imp of imports) {
2181
+ totalImports++;
2182
+ const resolved = resolveImport(imp, file, projectRoot);
2183
+ if (!resolved) {
2184
+ unmapped++;
2185
+ continue;
2186
+ }
2187
+ const targetNode = findNodeByPath(resolved, nodePathPrefixes);
2188
+ if (!targetNode) {
2189
+ unmapped++;
2190
+ continue;
2191
+ }
2192
+ const targetLayerRef = String(targetNode.data?.layerRef ?? "");
2193
+ const targetLayer = layerMap.get(targetLayerRef);
2194
+ if (!targetLayer) continue;
2195
+ if (targetLayerRef === layerRef) continue;
2196
+ const allowed = allowedDeps.get(layerRef);
2197
+ if (allowed && !allowed.has(targetLayerRef)) {
2198
+ const v = {
2199
+ sourceNode: node.name,
2200
+ sourceLayer: layer.name,
2201
+ sourceFile: relFile,
2202
+ importPath: imp,
2203
+ targetNode: targetNode.name,
2204
+ targetLayer: targetLayer.name,
2205
+ rule: `${layer.name} cannot import from ${targetLayer.name}`
2206
+ };
2207
+ violations.push(v);
2208
+ nodeViolations.push(v);
2209
+ }
2210
+ }
2211
+ }
2212
+ }
2213
+ nodeResults.set(node.entryId, { violations: nodeViolations, filesScanned: nodeFileCount });
2214
+ }
2215
+ return { violations, filesScanned: totalFiles, importsChecked: totalImports, unmappedImports: unmapped, nodeResults };
2216
+ }
2217
+ function buildAllowedDeps(layers) {
2218
+ const nameToId = /* @__PURE__ */ new Map();
2219
+ for (const l of layers) nameToId.set(l.name.toLowerCase(), l.entryId);
2220
+ const allowed = /* @__PURE__ */ new Map();
2221
+ for (const layer of layers) {
2222
+ const deps = String(layer.data?.dependsOn ?? "none");
2223
+ const set = /* @__PURE__ */ new Set();
2224
+ if (deps !== "none") {
2225
+ for (const dep of deps.split(",").map((d) => d.trim().toLowerCase())) {
2226
+ const id = nameToId.get(dep);
2227
+ if (id) set.add(id);
2228
+ }
2229
+ }
2230
+ allowed.set(layer.entryId, set);
2231
+ }
2232
+ return allowed;
2233
+ }
2234
+ function buildNodePrefixes(nodes) {
2235
+ const entries = [];
2236
+ for (const node of nodes) {
2237
+ for (const fp of parseFilePaths(node)) {
2238
+ entries.push({ prefix: normalize(fp), node });
2239
+ }
2240
+ }
2241
+ entries.sort((a, b) => b.prefix.length - a.prefix.length);
2242
+ return entries;
2243
+ }
2244
+ function parseFilePaths(node) {
2245
+ const raw = node.data?.filePaths;
2246
+ if (!raw || typeof raw !== "string") return [];
2247
+ return raw.split(",").map((p) => p.trim()).filter(Boolean);
2248
+ }
2249
+ function collectFiles(absPath) {
2250
+ if (!existsSync2(absPath)) return [];
2251
+ const stat = statSync(absPath);
2252
+ if (stat.isFile()) {
2253
+ return isScannableFile(absPath) ? [absPath] : [];
2254
+ }
2255
+ if (!stat.isDirectory()) return [];
2256
+ const results = [];
2257
+ const walk = (dir) => {
2258
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2259
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2260
+ const full = resolve2(dir, entry.name);
2261
+ if (entry.isDirectory()) walk(full);
2262
+ else if (isScannableFile(full)) results.push(full);
2263
+ }
2264
+ };
2265
+ walk(absPath);
2266
+ return results;
2267
+ }
2268
+ function isScannableFile(p) {
2269
+ return /\.(ts|js|svelte)$/.test(p) && !p.endsWith(".d.ts");
2270
+ }
2271
+ function parseImports(filePath) {
2272
+ try {
2273
+ const content = readFileSync2(filePath, "utf-8");
2274
+ const re = /(?:^|\n)\s*import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
2275
+ const imports = [];
2276
+ let match;
2277
+ while ((match = re.exec(content)) !== null) {
2278
+ imports.push(match[1]);
2279
+ }
2280
+ return imports;
2281
+ } catch {
2282
+ return [];
2283
+ }
2284
+ }
2285
+ var EXTENSIONS = [".ts", ".js", ".svelte", "/index.ts", "/index.js", "/index.svelte"];
2286
+ function tryResolveWithExtension(absPath) {
2287
+ if (existsSync2(absPath) && statSync(absPath).isFile()) return absPath;
2288
+ for (const ext of EXTENSIONS) {
2289
+ const withExt = absPath + ext;
2290
+ if (existsSync2(withExt)) return withExt;
2291
+ }
2292
+ return null;
2293
+ }
2294
+ function resolveImport(imp, fromFile, root) {
2295
+ let rel = null;
2296
+ if (imp.startsWith("$lib/")) rel = imp.replace("$lib/", "src/lib/");
2297
+ else if (imp.startsWith("$convex/")) rel = imp.replace("$convex/", "convex/");
2298
+ else if (imp.startsWith("$env/") || imp.startsWith("$app/")) return null;
2299
+ else if (imp.startsWith("./") || imp.startsWith("../")) {
2300
+ const fromDir = dirname(fromFile);
2301
+ const abs2 = resolve2(fromDir, imp);
2302
+ rel = relative(root, abs2);
2303
+ }
2304
+ if (!rel) return null;
2305
+ const abs = resolve2(root, rel);
2306
+ const actual = tryResolveWithExtension(abs);
2307
+ return actual ? relative(root, actual) : rel;
2308
+ }
2309
+ function findNodeByPath(filePath, prefixes) {
2310
+ const normalized = normalize(filePath);
2311
+ for (const { prefix, node } of prefixes) {
2312
+ if (normalized.startsWith(prefix)) return node;
2313
+ }
2314
+ return null;
2315
+ }
2316
+ function formatScanReport(result) {
2317
+ const lines = [];
2318
+ if (result.violations.length === 0) {
2319
+ lines.push(
2320
+ `# Architecture Health Check Passed`,
2321
+ "",
2322
+ `**0 violations** across ${result.filesScanned} files (${result.importsChecked} imports checked, ${result.unmappedImports} unmapped).`,
2323
+ "",
2324
+ "All imports respect the layer dependency rules."
2325
+ );
2326
+ } else {
2327
+ lines.push(
2328
+ `# Architecture Health Check \u2014 ${result.violations.length} Violation${result.violations.length === 1 ? "" : "s"}`,
2329
+ "",
2330
+ `Scanned ${result.filesScanned} files, checked ${result.importsChecked} imports, found **${result.violations.length} violation${result.violations.length === 1 ? "" : "s"}** (${result.unmappedImports} unmapped).`,
2331
+ ""
2332
+ );
2333
+ const byNode = /* @__PURE__ */ new Map();
2334
+ for (const v of result.violations) {
2335
+ if (!byNode.has(v.sourceNode)) byNode.set(v.sourceNode, []);
2336
+ byNode.get(v.sourceNode).push(v);
2337
+ }
2338
+ for (const [nodeName, vs] of byNode) {
2339
+ lines.push(`## ${nodeName} (${vs[0].sourceLayer})`);
2340
+ for (const v of vs) {
2341
+ lines.push(`- \`${v.sourceFile}\` imports \`${v.importPath}\` \u2192 **${v.targetNode}** (${v.targetLayer}) \u2014 ${v.rule}`);
2342
+ }
2343
+ lines.push("");
2344
+ }
2345
+ }
2346
+ lines.push("---", "");
2347
+ const nodeEntries = [...result.nodeResults.entries()];
2348
+ const cleanCount = nodeEntries.filter(([, r]) => r.violations.length === 0 && r.filesScanned > 0).length;
2349
+ const dirtyCount = nodeEntries.filter(([, r]) => r.violations.length > 0).length;
2350
+ const emptyCount = nodeEntries.filter(([, r]) => r.filesScanned === 0).length;
2351
+ lines.push(
2352
+ `**Summary:** ${cleanCount} clean nodes, ${dirtyCount} with violations, ${emptyCount} with no files.`
2353
+ );
2354
+ return lines.join("\n");
2355
+ }
2356
+
2357
+ // src/tools/workflows.ts
2358
+ import { z as z6 } from "zod";
2359
+
2360
+ // src/workflows/definitions.ts
2361
+ var RETRO_WORKFLOW = {
2362
+ id: "retro",
2363
+ name: "Retrospective",
2364
+ shortDescription: "Structured team retrospective \u2014 reflect, surface patterns, commit to actions. Commits a decision entry to the Chain.",
2365
+ icon: "\u25CE",
2366
+ facilitatorPreamble: `You are now in **Facilitator Mode**. You are not a coding assistant \u2014 you are a facilitator running a structured retrospective.
2367
+
2368
+ ## Your Behavior
2369
+
2370
+ 1. **Guide, don't solve.** Ask questions, reflect back, synthesize. Never jump to solutions.
2371
+ 2. **One round at a time.** Complete each round fully before moving to the next. No skipping.
2372
+ 3. **Use structured questions** (AskQuestion tool) for choices and multi-select. Use open conversation for reflection.
2373
+ 4. **Create a Plan** at the start showing all rounds as tasks. Update it as you progress.
2374
+ 5. **Synthesize between rounds.** After collecting input, reflect back what you heard before moving on.
2375
+ 6. **Never go silent.** If something fails, say what happened and what to do next.
2376
+ 7. **Commit to the Chain** at the end using the Product OS capture tool.
2377
+ 8. **Match the energy.** Be warm but structured. This is a ceremony, not a checklist.
2378
+
2379
+ ## Communication Style
2380
+
2381
+ - Start each round with its number, name, and a brief instruction
2382
+ - Use quotes and emphasis to reflect back what the participant said
2383
+ - End each round with a brief synthesis before transitioning
2384
+ - When a round is complete, mark it as done in the Plan
2385
+ - If the participant seems stuck, offer prompts \u2014 never pressure
2386
+ - If a tool call fails, explain what happened and offer an alternative path
2387
+
2388
+ ## Error Recovery
2389
+
2390
+ If at any point a tool call or MCP operation fails:
2391
+ 1. Tell the participant what you were trying to do
2392
+ 2. Explain what went wrong (briefly, no stack traces)
2393
+ 3. Offer a manual alternative (e.g., "I'll capture this in the conversation instead")
2394
+ 4. Continue the workflow \u2014 never halt completely
2395
+
2396
+ ## Plan Structure
2397
+
2398
+ Create a Cursor Plan with these rounds as tasks. Mark each in_progress as you enter it, completed when done.`,
2399
+ rounds: [
2400
+ {
2401
+ id: "set-stage",
2402
+ num: "01",
2403
+ label: "Set the Stage",
2404
+ type: "choice",
2405
+ instruction: "Before we dive in, let's frame what we're reflecting on. What's the scope of this retro?",
2406
+ facilitatorGuidance: "Start warm. Ask the participant to pick or describe what they're retro-ing. Confirm the scope before proceeding. If they gave context already in their initial message, use it \u2014 don't make them repeat.",
2407
+ questions: [
2408
+ {
2409
+ id: "scope",
2410
+ prompt: "What are we reflecting on? Pick one or describe your own.",
2411
+ options: [
2412
+ { id: "last-week", label: "Last week's work" },
2413
+ { id: "last-sprint", label: "Last sprint/cycle" },
2414
+ { id: "specific-project", label: "A specific project or feature" },
2415
+ { id: "process", label: "A process or workflow" },
2416
+ { id: "custom", label: "Something else \u2014 let me describe it" }
2417
+ ]
2418
+ }
2419
+ ],
2420
+ outputSchema: {
2421
+ field: "scope",
2422
+ description: "What timeframe or project is being retro'd",
2423
+ format: "freetext"
2424
+ },
2425
+ maxDurationHint: "2 min"
2426
+ },
2427
+ {
2428
+ id: "what-went-well",
2429
+ num: "02",
2430
+ label: "What Went Well",
2431
+ type: "open",
2432
+ instruction: "Let's start with the good. What went well? What are you proud of? What should we do more of?",
2433
+ facilitatorGuidance: "Celebrate. Reflect back each win with genuine emphasis. Ask follow-ups like 'What made that work?' or 'Who else contributed to that?' Collect at least 3 items before synthesizing. Don't rush past the positive \u2014 teams skip this too fast.",
2434
+ outputSchema: {
2435
+ field: "wentWell",
2436
+ description: "List of things that went well",
2437
+ format: "list"
2438
+ },
2439
+ maxDurationHint: "5 min"
2440
+ },
2441
+ {
2442
+ id: "what-didnt-go-well",
2443
+ num: "03",
2444
+ label: "What Didn't Go Well",
2445
+ type: "open",
2446
+ instruction: "Now the harder part. What didn't go well? What frustrated you? Where did things break down?",
2447
+ facilitatorGuidance: "Create safety. Acknowledge that this is harder. Don't judge or immediately problem-solve \u2014 just listen and capture. Ask 'What was the impact of that?' and 'When did you first notice it?'. Reflect back without softening. Collect at least 3 items.",
2448
+ outputSchema: {
2449
+ field: "didntGoWell",
2450
+ description: "List of things that didn't go well",
2451
+ format: "list"
2452
+ },
2453
+ maxDurationHint: "5 min"
2454
+ },
2455
+ {
2456
+ id: "patterns",
2457
+ num: "04",
2458
+ label: "Patterns & Insights",
2459
+ type: "synthesis",
2460
+ instruction: "Looking at what went well and what didn't \u2014 what patterns do you see? What's the deeper insight?",
2461
+ facilitatorGuidance: "This is YOUR moment as facilitator. Synthesize what you've heard across rounds 2 and 3. Surface themes, connections, and contradictions. Propose 2-3 patterns and ask the participant to react. This round transforms raw observations into actionable insights. Don't let the participant skip the 'why' \u2014 push for root causes.",
2462
+ outputSchema: {
2463
+ field: "patterns",
2464
+ description: "Synthesized patterns and insights",
2465
+ format: "structured"
2466
+ },
2467
+ maxDurationHint: "5 min"
2468
+ },
2469
+ {
2470
+ id: "actions",
2471
+ num: "05",
2472
+ label: "Actions & Commitments",
2473
+ type: "commit",
2474
+ instruction: "Based on these patterns, what will we actually change? Be specific \u2014 who does what, by when?",
2475
+ facilitatorGuidance: "Push for specificity. 'Be better at X' is not an action. 'Randy will set up a 15-min weekly check-in by Friday' is. Each action needs an owner and a deadline. Aim for 2-4 concrete actions. Use AskQuestion to confirm the final list. These become the retro's output.",
2476
+ questions: [
2477
+ {
2478
+ id: "action-confirm",
2479
+ prompt: "Are these actions concrete enough to actually happen?",
2480
+ options: [
2481
+ { id: "yes", label: "Yes \u2014 these are clear and actionable" },
2482
+ { id: "refine", label: "Let me refine some of these" },
2483
+ { id: "add", label: "I want to add more" }
2484
+ ]
2485
+ }
2486
+ ],
2487
+ outputSchema: {
2488
+ field: "actions",
2489
+ description: "Committed actions with owners and deadlines",
2490
+ format: "structured"
2491
+ },
2492
+ maxDurationHint: "5 min"
2493
+ },
2494
+ {
2495
+ id: "close",
2496
+ num: "06",
2497
+ label: "Close & Capture",
2498
+ type: "close",
2499
+ instruction: "One last thing \u2014 in one sentence, what's the single most important thing you're taking away from this retro?",
2500
+ facilitatorGuidance: "Keep it brief. One sentence reflection. Then summarize the entire retro: scope, key wins, key pain points, patterns identified, actions committed. Ask if they want to commit this to the Chain. If yes, use capture to create a decision/tension entry. Thank them for the retro.",
2501
+ outputSchema: {
2502
+ field: "takeaway",
2503
+ description: "Single-sentence takeaway",
2504
+ format: "freetext"
2505
+ },
2506
+ kbCollection: "decisions",
2507
+ maxDurationHint: "2 min"
2508
+ }
2509
+ ],
2510
+ kbOutputCollection: "decisions",
2511
+ kbOutputTemplate: {
2512
+ nameTemplate: "Retro: {scope} \u2014 {date}",
2513
+ descriptionField: "rationale"
2514
+ },
2515
+ errorRecovery: `If anything goes wrong during the retro:
2516
+
2517
+ 1. **MCP tool failure**: Skip the chain commit step. Summarize everything in the conversation instead. Suggest the participant runs \`capture\` manually later.
2518
+ 2. **AskQuestion not available**: Fall back to numbered options in plain text. "Reply with 1, 2, or 3."
2519
+ 3. **Plan creation fails**: Continue without the Plan. The conversation IS the record.
2520
+ 4. **Participant goes off-topic**: Gently redirect: "That's valuable \u2014 let's capture it. For now, let's stay with [current round]."
2521
+ 5. **Participant wants to stop**: Respect it. Summarize what you have so far. Offer to commit partial results to the Chain.
2522
+
2523
+ The retro must never fail silently. Always communicate state.`
2524
+ };
2525
+ var WORKFLOWS = /* @__PURE__ */ new Map([
2526
+ ["retro", RETRO_WORKFLOW]
2527
+ ]);
2528
+ function getWorkflow(id) {
2529
+ return WORKFLOWS.get(id);
2530
+ }
2531
+ function listWorkflows() {
2532
+ return Array.from(WORKFLOWS.values());
2533
+ }
2534
+
2535
+ // src/tools/workflows.ts
2536
+ function formatWorkflowCard(wf) {
2537
+ const roundList = wf.rounds.map((r) => ` ${r.num}. ${r.label} (${r.type}, ~${r.maxDurationHint ?? "?"})`).join("\n");
2538
+ return `## ${wf.icon} ${wf.name}
2539
+ **ID**: \`${wf.id}\`
2540
+ ${wf.shortDescription}
2541
+
2542
+ **Rounds** (${wf.rounds.length}):
2543
+ ${roundList}
2544
+
2545
+ **Output**: Creates entries in \`${wf.kbOutputCollection}\` collection.
2546
+ _Use the \`run-workflow\` prompt with workflow="${wf.id}" to start._`;
2547
+ }
2548
+ function registerWorkflowTools(server) {
2549
+ server.registerTool(
2550
+ "list-workflows",
2551
+ {
2552
+ title: "List Workflows",
2553
+ description: "List all available Chainwork workflows \u2014 retro, shape-a-bet, IDM, etc. Each workflow is a structured multi-round facilitation ceremony that the agent runs in Facilitator Mode. Use the `run-workflow` prompt to actually launch one.",
2554
+ annotations: { readOnlyHint: true }
2555
+ },
2556
+ async () => {
2557
+ const workflows = listWorkflows();
2558
+ if (workflows.length === 0) {
2559
+ return {
2560
+ content: [{
2561
+ type: "text",
2562
+ text: "No workflows registered yet. Check back after the workflow definitions are configured."
2563
+ }]
2564
+ };
2565
+ }
2566
+ const cards = workflows.map(formatWorkflowCard).join("\n\n---\n\n");
2567
+ return {
2568
+ content: [{
2569
+ type: "text",
2570
+ text: `# Available Chainwork Workflows
2571
+
2572
+ ${workflows.length} workflow(s) available. Use the \`run-workflow\` prompt to launch one.
2573
+
2574
+ ---
2575
+
2576
+ ${cards}`
2577
+ }]
2578
+ };
2579
+ }
2580
+ );
2581
+ server.registerTool(
2582
+ "workflow-checkpoint",
2583
+ {
2584
+ title: "Workflow Checkpoint",
2585
+ description: "Record the output of a workflow round. Captures the round's data to the Chain as a structured entry. Use this during Facilitator Mode after completing each round to persist progress \u2014 so if the conversation is interrupted, work is not lost.\n\nAt workflow completion, this tool can also generate the final summary entry.",
2586
+ inputSchema: {
2587
+ workflowId: z6.string().describe("Workflow ID (e.g., 'retro')"),
2588
+ roundId: z6.string().describe("Round ID (e.g., 'what-went-well')"),
2589
+ output: z6.string().describe("The round's output \u2014 synthesized by the facilitator from the conversation"),
2590
+ isFinal: z6.boolean().optional().describe(
2591
+ "If true, this is the final checkpoint and triggers the summary chain entry creation"
2592
+ ),
2593
+ summaryName: z6.string().optional().describe(
2594
+ "Name for the final chain entry (required when isFinal=true)"
2595
+ ),
2596
+ summaryDescription: z6.string().optional().describe(
2597
+ "Full description/rationale for the final chain entry (required when isFinal=true)"
2598
+ )
2599
+ },
2600
+ annotations: { destructiveHint: false }
2601
+ },
2602
+ async ({ workflowId, roundId, output, isFinal, summaryName, summaryDescription }) => {
2603
+ const wf = getWorkflow(workflowId);
2604
+ if (!wf) {
2605
+ return {
2606
+ content: [{
2607
+ type: "text",
2608
+ text: `Workflow "${workflowId}" not found. Available: ${listWorkflows().map((w) => w.id).join(", ")}.
2609
+
2610
+ This checkpoint was NOT saved. Continue the conversation \u2014 the facilitator has the context.`
2611
+ }]
2612
+ };
2613
+ }
2614
+ const round = wf.rounds.find((r) => r.id === roundId);
2615
+ if (!round) {
2616
+ return {
2617
+ content: [{
2618
+ type: "text",
2619
+ text: `Round "${roundId}" not found in workflow "${workflowId}". Available rounds: ${wf.rounds.map((r) => r.id).join(", ")}.
2620
+
2621
+ This checkpoint was NOT saved. The conversation context is preserved \u2014 continue facilitating.`
2622
+ }]
2623
+ };
2624
+ }
2625
+ const lines = [
2626
+ `## Checkpoint: Round ${round.num} \u2014 ${round.label}`,
2627
+ `Workflow: ${wf.name} (\`${wf.id}\`)`,
2628
+ ""
2629
+ ];
2630
+ if (isFinal && summaryName && summaryDescription) {
2631
+ try {
2632
+ const entryId = await mcpMutation("chain.createEntry", {
2633
+ collectionSlug: wf.kbOutputCollection,
2634
+ name: summaryName,
2635
+ status: "draft",
2636
+ data: {
2637
+ [wf.kbOutputTemplate.descriptionField]: summaryDescription,
2638
+ workflowType: wf.id,
2639
+ completedRound: roundId,
2640
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
2641
+ },
2642
+ createdBy: `workflow:${wf.id}`
2643
+ });
2644
+ lines.push(
2645
+ `**Entry Committed**: \`${entryId}\``,
2646
+ `Collection: \`${wf.kbOutputCollection}\``,
2647
+ `Name: ${summaryName}`,
2648
+ "",
2649
+ `The retro is now committed to the Chain. `,
2650
+ `Use \`suggest-links\` on this entry to connect it to related knowledge.`
2651
+ );
2652
+ } catch (err) {
2653
+ const msg = err instanceof Error ? err.message : String(err);
2654
+ lines.push(
2655
+ `**Chain commit failed**: ${msg}`,
2656
+ "",
2657
+ `The retro output is preserved in this conversation. `,
2658
+ `You can manually create the entry later using \`capture\` with:`,
2659
+ `- Collection: \`${wf.kbOutputCollection}\``,
2660
+ `- Name: ${summaryName}`,
2661
+ `- Description: (copy from the conversation summary above)`
2662
+ );
2663
+ }
2664
+ } else {
2665
+ lines.push(
2666
+ `Round ${round.num} output recorded.`,
2667
+ `Output: ${output.substring(0, 200)}${output.length > 200 ? "..." : ""}`
2668
+ );
2669
+ const currentIdx = wf.rounds.findIndex((r) => r.id === roundId);
2670
+ if (currentIdx < wf.rounds.length - 1) {
2671
+ const next = wf.rounds[currentIdx + 1];
2672
+ lines.push(
2673
+ "",
2674
+ `**Next**: Round ${next.num} \u2014 ${next.label}`,
2675
+ `_${next.instruction}_`
2676
+ );
2677
+ } else {
2678
+ lines.push(
2679
+ "",
2680
+ `**All rounds complete.** Call this tool again with \`isFinal: true\` to commit to the Chain.`
2681
+ );
2682
+ }
2683
+ }
2684
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2685
+ }
2686
+ );
2687
+ }
2688
+
2689
+ // src/tools/session.ts
2690
+ function registerSessionTools(server) {
2691
+ server.registerTool(
2692
+ "agent-start",
2693
+ {
2694
+ title: "Start Agent Session",
2695
+ description: "Start an agent session. Creates a tracked session for this workspace with full attribution. If a session is already active, it gets superseded (graceful handover). Write tools are available after calling orient.",
2696
+ annotations: { readOnlyHint: false }
2697
+ },
2698
+ async () => {
2699
+ try {
2700
+ const result = await startAgentSession();
2701
+ const lines = [];
2702
+ if (result.superseded) {
2703
+ lines.push(
2704
+ `Previous session superseded. Session ${result.superseded.previousSessionId} (started ${result.superseded.startedAt}, initiated by ${result.superseded.initiatedBy}) was closed.`,
2705
+ ""
2706
+ );
2707
+ }
2708
+ lines.push(
2709
+ `Session ${result.sessionId} active. Initiated by ${result.initiatedBy}. Workspace ${result.workspaceName}. Scope: ${result.toolsScope}. Write tools available after orient.`
2710
+ );
2711
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2712
+ } catch (err) {
2713
+ return {
2714
+ content: [{ type: "text", text: `Failed to start agent session: ${err.message}` }],
2715
+ isError: true
2716
+ };
2717
+ }
2718
+ }
2719
+ );
2720
+ server.registerTool(
2721
+ "agent-close",
2722
+ {
2723
+ title: "Close Agent Session",
2724
+ description: "Close the current agent session. Records structured session data (entries created, modified, relations, gate results). After this, write tools are blocked even if the MCP connection stays open.",
2725
+ annotations: { readOnlyHint: false }
2726
+ },
2727
+ async () => {
2728
+ try {
2729
+ const sessionId = getAgentSessionId();
2730
+ if (!sessionId) {
2731
+ return {
2732
+ content: [{ type: "text", text: "No active agent session to close." }]
2733
+ };
2734
+ }
2735
+ const session = await mcpCall("agent.getSession", {
2736
+ sessionId
2737
+ });
2738
+ await closeAgentSession();
2739
+ const lines = [
2740
+ `Session ${sessionId} closed.`,
2741
+ ""
2742
+ ];
2743
+ if (session) {
2744
+ const created = session.entriesCreated?.length ?? 0;
2745
+ const modified = session.entriesModified?.length ?? 0;
2746
+ const relations = session.relationsCreated ?? 0;
2747
+ const gates = session.gateFailures ?? 0;
2748
+ const warnings = session.contradictionWarnings ?? 0;
2749
+ lines.push(
2750
+ `| Metric | Count |`,
2751
+ `|--------|-------|`,
2752
+ `| Entries created | ${created} |`,
2753
+ `| Entries modified | ${modified} |`,
2754
+ `| Relations created | ${relations} |`,
2755
+ `| Gate failures | ${gates} |`,
2756
+ `| Contradiction warnings | ${warnings} |`
2757
+ );
2758
+ }
2759
+ lines.push("", "Write tools are now blocked. Session data saved for future orientation.");
2760
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2761
+ } catch (err) {
2762
+ return {
2763
+ content: [{ type: "text", text: `Failed to close agent session: ${err.message}` }],
2764
+ isError: true
2765
+ };
2766
+ }
2767
+ }
2768
+ );
2769
+ server.registerTool(
2770
+ "agent-status",
2771
+ {
2772
+ title: "Agent Session Status",
2773
+ description: "Check the current agent session status \u2014 whether a session is active, whether orientation is complete, and session activity so far.",
2774
+ annotations: { readOnlyHint: true }
2775
+ },
2776
+ async () => {
2777
+ try {
2778
+ const sessionId = getAgentSessionId();
2779
+ if (!sessionId) {
2780
+ return {
2781
+ content: [{ type: "text", text: "No active agent session. Call `agent-start` to begin." }]
2782
+ };
2783
+ }
2784
+ const session = await mcpCall("agent.getSession", {
2785
+ sessionId
2786
+ });
2787
+ if (!session) {
2788
+ return {
2789
+ content: [{ type: "text", text: "Session ID cached but not found in Convex. Call `agent-start` to create a new session." }]
2790
+ };
2791
+ }
2792
+ const oriented = isSessionOriented();
2793
+ const created = session.entriesCreated?.length ?? 0;
2794
+ const modified = session.entriesModified?.length ?? 0;
2795
+ const lines = [
2796
+ `Session ${sessionId} ${session.status}.`,
2797
+ `Initiated by ${session.initiatedBy}.`,
2798
+ `Scope: ${session.toolsScope}.`,
2799
+ `Oriented: ${oriented ? "yes" : "no"}.`,
2800
+ "",
2801
+ `| Metric | Value |`,
2802
+ `|--------|-------|`,
2803
+ `| Entries created | ${created} |`,
2804
+ `| Entries modified | ${modified} |`,
2805
+ `| Relations created | ${session.relationsCreated ?? 0} |`,
2806
+ `| Started | ${new Date(session.startedAt).toISOString()} |`,
2807
+ `| Expires | ${new Date(session.expiresAt).toISOString()} |`
2808
+ ];
2809
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2810
+ } catch (err) {
2811
+ return {
2812
+ content: [{ type: "text", text: `Failed to get session status: ${err.message}` }],
2813
+ isError: true
2814
+ };
2815
+ }
2816
+ }
2817
+ );
2818
+ }
2819
+
2820
+ // src/tools/gitchain.ts
2821
+ import { z as z7 } from "zod";
2822
+ function linkSummary(links) {
2823
+ return Object.entries(links).map(([id, content]) => {
2824
+ const filled = typeof content === "string" && content.length > 0;
2825
+ const preview = filled ? content.substring(0, 80) + (content.length > 80 ? "..." : "") : "(empty)";
2826
+ return ` - **${id}**: ${preview}`;
2827
+ }).join("\n");
2828
+ }
2829
+ function registerGitChainTools(server) {
2830
+ server.registerTool(
2831
+ "chain",
2832
+ {
2833
+ title: "Chain",
2834
+ description: "Manage processes \u2014 create, get, list, or edit process links. Processes are versioned knowledge artifacts that follow a process template (e.g. Strategy Coherence: Problem \u2192 Insight \u2192 Choice \u2192 Action \u2192 Outcome).",
2835
+ inputSchema: {
2836
+ action: z7.enum(["create", "get", "list", "edit"]).describe("Action: create a process, get process details, list all processes, or edit a process link"),
2837
+ chainEntryId: z7.string().optional().describe("Chain entry ID (required for get/edit)"),
2838
+ title: z7.string().optional().describe("Process title (required for create)"),
2839
+ chainTypeId: z7.string().optional().default("strategy-coherence").describe("Process template slug for create: 'strategy-coherence', 'idm-proposal', or any custom template slug"),
2840
+ description: z7.string().optional().describe("Description (for create)"),
2841
+ linkId: z7.string().optional().describe("Link to edit (for edit action): problem, insight, choice, action, outcome"),
2842
+ content: z7.string().optional().describe("New content for the link (for edit action)"),
2843
+ status: z7.string().optional().describe("Filter by status for list: 'draft' or 'active'"),
2844
+ author: z7.string().optional().describe("Who is performing the action. Defaults to 'mcp'.")
2845
+ }
2846
+ },
2847
+ async ({ action, chainEntryId, title, chainTypeId, description, linkId, content, status, author }) => {
2848
+ if (action === "create") {
2849
+ if (!title) return { content: [{ type: "text", text: "A `title` is required to create a process." }] };
2850
+ const result = await mcpMutation(
2851
+ "gitchain.createChain",
2852
+ { title, chainTypeId, description, author }
2853
+ );
2854
+ return {
2855
+ content: [{
2856
+ type: "text",
2857
+ text: `# Process Created
2858
+
2859
+ - **Entry ID:** \`${result.entryId}\`
2860
+ - **Title:** ${title}
2861
+ - **Type:** ${chainTypeId}
2862
+ - **Status:** draft
2863
+
2864
+ Use \`chain action=edit chainEntryId="${result.entryId}" linkId="problem" content="..."\` to start filling in links.`
2865
+ }]
2866
+ };
2867
+ }
2868
+ if (action === "get") {
2869
+ if (!chainEntryId) return { content: [{ type: "text", text: "A `chainEntryId` is required." }] };
2870
+ const chain = await mcpQuery("gitchain.getChain", { chainEntryId });
2871
+ if (!chain) {
2872
+ return { content: [{ type: "text", text: `Process "${chainEntryId}" not found.` }] };
2873
+ }
2874
+ const scoreSection = chain.scores ? `
2875
+ ## Coherence: ${chain.coherenceScore}%
2876
+
2877
+ ` + chain.scores.sections.map((s) => `- **${s.key}**: ${s.power}/100 (${"\u2605".repeat(Math.min(s.stars ?? 0, 5))}${"\u2606".repeat(Math.max(0, 5 - (s.stars ?? 0)))})`).join("\n") : "";
2878
+ return {
2879
+ content: [{
2880
+ type: "text",
2881
+ text: `# ${chain.name}
2882
+
2883
+ - **Entry ID:** \`${chain.entryId}\`
2884
+ - **Type:** ${chain.chainTypeName}
2885
+ - **Status:** ${chain.status}
2886
+ - **Links filled:** ${chain.filledCount}/${chain.totalCount}
2887
+ - **Version:** ${chain.currentVersion}
2888
+ - **Created by:** ${chain.createdBy ?? "unknown"}
2889
+ - **History events:** ${chain.historyCount}
2890
+ ` + scoreSection + `
2891
+
2892
+ ## Links
2893
+
2894
+ ` + linkSummary(chain.links)
2895
+ }]
2896
+ };
2897
+ }
2898
+ if (action === "list") {
2899
+ const chains = await mcpQuery("gitchain.listChains", {
2900
+ chainTypeId,
2901
+ status
2902
+ });
2903
+ if (chains.length === 0) {
2904
+ return { content: [{ type: "text", text: "No processes found. Use `chain action=create` to create one." }] };
2905
+ }
2906
+ const formatted = chains.map(
2907
+ (c) => `- **\`${c.entryId}\`** ${c.name} \u2014 ${c.chainTypeId} \xB7 ${c.filledCount}/${c.totalCount} links \xB7 coherence: ${c.coherenceScore}% \xB7 status: ${c.status}`
2908
+ ).join("\n");
2909
+ return { content: [{ type: "text", text: `# Chains (${chains.length})
2910
+
2911
+ ${formatted}` }] };
2912
+ }
2913
+ if (action === "edit") {
2914
+ if (!chainEntryId) return { content: [{ type: "text", text: "A `chainEntryId` is required." }] };
2915
+ if (!linkId) return { content: [{ type: "text", text: "A `linkId` is required (e.g. problem, insight, choice, action, outcome)." }] };
2916
+ if (!content) return { content: [{ type: "text", text: "The `content` for the link is required." }] };
2917
+ const result = await mcpMutation("gitchain.editLink", { chainEntryId, linkId, content, author });
2918
+ return {
2919
+ content: [{
2920
+ type: "text",
2921
+ text: `# Link Updated
2922
+
2923
+ - **Chain:** \`${result.entryId}\`
2924
+ - **Link:** ${result.linkId}
2925
+ - **Chain status:** ${result.status}
2926
+ - **Content length:** ${content.length} chars
2927
+
2928
+ Use \`chain action=get\` to see the full chain with updated scores.`
2929
+ }]
2930
+ };
2931
+ }
2932
+ return { content: [{ type: "text", text: "Unknown action." }] };
2933
+ }
2934
+ );
2935
+ server.registerTool(
2936
+ "chain-version",
2937
+ {
2938
+ title: "Chain Version",
2939
+ description: "Manage process versions \u2014 commit snapshots, list commits, view history, diff versions, or revert. Commits record all link content, compute coherence scores, and track changes.",
2940
+ inputSchema: {
2941
+ action: z7.enum(["commit", "list", "diff", "revert", "history"]).describe("Action: commit a snapshot, list commits, diff two versions, revert to a version, or view history"),
2942
+ chainEntryId: z7.string().describe("The chain's entry ID"),
2943
+ commitMessage: z7.string().optional().describe("Commit message (required for commit). Convention: type(link): description"),
2944
+ versionA: z7.number().optional().describe("Earlier version for diff"),
2945
+ versionB: z7.number().optional().describe("Later version for diff"),
2946
+ toVersion: z7.number().optional().describe("Version number to revert to"),
2947
+ author: z7.string().optional().describe("Who is performing the action. Defaults to 'mcp'.")
2948
+ },
2949
+ annotations: { readOnlyHint: false }
2950
+ },
2951
+ async ({ action, chainEntryId, commitMessage, versionA, versionB, toVersion, author }) => {
2952
+ if (action === "commit") {
2953
+ if (!commitMessage) return { content: [{ type: "text", text: "A `commitMessage` is required." }] };
2954
+ const result = await mcpMutation("gitchain.commitChain", { chainEntryId, commitMessage, author });
2955
+ const warning = result.commitLintWarning ? `
2956
+
2957
+ > **Lint warning:** ${result.commitLintWarning}` : "";
2958
+ return {
2959
+ content: [{
2960
+ type: "text",
2961
+ text: `# Committed v${result.version}
2962
+
2963
+ - **Chain:** \`${result.entryId}\`
2964
+ - **Version:** ${result.version}
2965
+ - **Message:** ${commitMessage}
2966
+ - **Coherence:** ${result.coherenceScore}%
2967
+ - **Links modified:** ${result.linksModified.length > 0 ? result.linksModified.join(", ") : "none"}
2968
+ ` + warning
2969
+ }]
2970
+ };
2971
+ }
2972
+ if (action === "list") {
2973
+ const commits = await mcpQuery("gitchain.listCommits", { chainEntryId });
2974
+ if (commits.length === 0) {
2975
+ return { content: [{ type: "text", text: `No commits found for chain "${chainEntryId}". Use \`chain-version action=commit\` to create the first snapshot.` }] };
2976
+ }
2977
+ const formatted = commits.map((c) => {
2978
+ const date = new Date(c.createdAt).toISOString().replace("T", " ").substring(0, 19);
2979
+ const msg = c.commitMessage ?? c.changeNote ?? "(no message)";
2980
+ const links = c.linksModified?.length > 0 ? ` [${c.linksModified.join(", ")}]` : "";
2981
+ return `- **v${c.version}** ${date} by ${c.author} \u2014 ${msg}${links} (${c.versionStatus})`;
2982
+ }).join("\n");
2983
+ return { content: [{ type: "text", text: `# Commits for ${chainEntryId} (${commits.length})
2984
+
2985
+ ${formatted}` }] };
2986
+ }
2987
+ if (action === "diff") {
2988
+ if (versionA == null || versionB == null) {
2989
+ return { content: [{ type: "text", text: "Both `versionA` and `versionB` are required for diff." }] };
2990
+ }
2991
+ const diff = await mcpMutation("gitchain.diffVersions", { chainEntryId, versionA, versionB });
2992
+ let text = `# Diff: v${versionA} \u2192 v${versionB}
2993
+
2994
+ - **Chain:** \`${diff.chainEntryId}\`
2995
+ - **Coherence:** ${diff.coherenceBefore}% \u2192 ${diff.coherenceAfter}% (${diff.coherenceDelta >= 0 ? "+" : ""}${diff.coherenceDelta})
2996
+ - **Links changed:** ${diff.linksChanged.length > 0 ? diff.linksChanged.join(", ") : "none"}
2997
+ `;
2998
+ for (const ld of diff.linkDiffs) {
2999
+ if (ld.status === "unchanged") continue;
3000
+ text += `
3001
+ ## ${ld.linkId} (${ld.status})
3002
+
3003
+ `;
3004
+ const wordDiff = diff.wordDiffs[ld.linkId];
3005
+ if (wordDiff?.length > 0) {
3006
+ for (const w of wordDiff) {
3007
+ if (w.type === "delete") text += `~~${w.value.substring(0, 200)}~~`;
3008
+ else if (w.type === "insert") text += `**${w.value.substring(0, 200)}**`;
3009
+ else text += w.value.substring(0, 200);
3010
+ }
3011
+ text += "\n";
3012
+ }
3013
+ }
3014
+ return { content: [{ type: "text", text }] };
3015
+ }
3016
+ if (action === "revert") {
3017
+ if (toVersion == null) return { content: [{ type: "text", text: "A `toVersion` is required for revert." }] };
3018
+ const result = await mcpMutation("gitchain.revertChain", { chainEntryId, toVersion, author });
3019
+ return {
3020
+ content: [{
3021
+ type: "text",
3022
+ text: `# Reverted
3023
+
3024
+ - **Chain:** \`${result.entryId}\`
3025
+ - **Reverted to:** v${result.revertedTo}
3026
+ - **New version:** v${result.newVersion}
3027
+ - **Links affected:** ${result.linksModified.length > 0 ? result.linksModified.join(", ") : "none"}
3028
+
3029
+ History is preserved \u2014 this created a new version, not a destructive reset.`
3030
+ }]
3031
+ };
3032
+ }
3033
+ if (action === "history") {
3034
+ const history = await mcpQuery("gitchain.getHistory", { chainEntryId });
3035
+ if (history.length === 0) {
3036
+ return { content: [{ type: "text", text: `No history found for chain "${chainEntryId}".` }] };
3037
+ }
3038
+ const formatted = history.sort((a, b) => b.timestamp - a.timestamp).map((h) => {
3039
+ const date = new Date(h.timestamp).toISOString().replace("T", " ").substring(0, 19);
3040
+ return `- **${date}** [${h.event}] by ${h.changedBy ?? "unknown"} \u2014 ${h.note ?? ""}`;
3041
+ }).join("\n");
3042
+ return { content: [{ type: "text", text: `# History for ${chainEntryId} (${history.length} events)
3043
+
3044
+ ${formatted}` }] };
3045
+ }
3046
+ return { content: [{ type: "text", text: "Unknown action." }] };
3047
+ }
3048
+ );
3049
+ server.registerTool(
3050
+ "chain-branch",
3051
+ {
3052
+ title: "Chain Branch",
3053
+ description: "Manage process branches \u2014 create a branch for isolated editing, list branches, merge a branch back into main, or check for conflicts.",
3054
+ inputSchema: {
3055
+ action: z7.enum(["create", "list", "merge", "conflicts"]).describe("Action: create a branch, list branches, merge a branch, or check for conflicts"),
3056
+ chainEntryId: z7.string().describe("The chain's entry ID"),
3057
+ branchName: z7.string().optional().describe("Branch name (required for merge/conflicts, optional for create)"),
3058
+ strategy: z7.enum(["merge_commit", "squash"]).optional().describe("Merge strategy: 'merge_commit' (default) or 'squash'"),
3059
+ author: z7.string().optional().describe("Who is performing the action. Defaults to 'mcp'.")
3060
+ }
3061
+ },
3062
+ async ({ action, chainEntryId, branchName, strategy, author }) => {
3063
+ if (action === "create") {
3064
+ const result = await mcpMutation("gitchain.createBranch", { chainEntryId, name: branchName, author });
3065
+ return {
3066
+ content: [{
3067
+ type: "text",
3068
+ text: `# Branch Created
3069
+
3070
+ - **Name:** ${result.name}
3071
+ - **Based on:** v${result.baseVersion}
3072
+ - **Chain:** \`${chainEntryId}\`
3073
+
3074
+ Edit links and commit on this branch, then use \`chain-branch action=merge\` to land changes.`
3075
+ }]
3076
+ };
3077
+ }
3078
+ if (action === "list") {
3079
+ const branches = await mcpMutation("gitchain.listBranches", { chainEntryId });
3080
+ if (branches.length === 0) {
3081
+ return { content: [{ type: "text", text: `No branches found for chain "${chainEntryId}".` }] };
3082
+ }
3083
+ const formatted = branches.map((b) => `- **${b.name}** (${b.status}) \u2014 based on v${b.baseVersion}, by ${b.createdBy}`).join("\n");
3084
+ return { content: [{ type: "text", text: `# Branches for ${chainEntryId} (${branches.length})
3085
+
3086
+ ${formatted}` }] };
3087
+ }
3088
+ if (action === "merge") {
3089
+ if (!branchName) return { content: [{ type: "text", text: "A `branchName` is required for merge." }] };
3090
+ const result = await mcpMutation("gitchain.mergeBranch", { chainEntryId, branchName, strategy, author });
3091
+ return {
3092
+ content: [{
3093
+ type: "text",
3094
+ text: `# Branch Merged
3095
+
3096
+ - **Chain:** \`${result.entryId}\`
3097
+ - **Branch:** ${result.branchName} (now closed)
3098
+ - **Version:** v${result.version}
3099
+ - **Strategy:** ${result.strategy}
3100
+
3101
+ Main is now at v${result.version}. The branch has been closed.`
3102
+ }]
3103
+ };
3104
+ }
3105
+ if (action === "conflicts") {
3106
+ if (!branchName) return { content: [{ type: "text", text: "A `branchName` is required for conflict check." }] };
3107
+ const result = await mcpMutation("gitchain.checkConflicts", { chainEntryId, branchName });
3108
+ if (!result.hasConflicts) {
3109
+ return {
3110
+ content: [{
3111
+ type: "text",
3112
+ text: `# No Conflicts
3113
+
3114
+ Branch "${branchName}" on \`${chainEntryId}\` has no conflicts. Safe to merge.`
3115
+ }]
3116
+ };
3117
+ }
3118
+ const conflictLines = result.conflicts.map(
3119
+ (c) => `- **${c.linkId}** \u2014 modified by: ${c.branches.map((b) => `${b.branchName} (${b.author})`).join(", ")}`
3120
+ ).join("\n");
3121
+ return {
3122
+ content: [{
3123
+ type: "text",
3124
+ text: `# Conflicts Detected
3125
+
3126
+ Branch "${branchName}" conflicts with other branches on these links:
3127
+
3128
+ ` + conflictLines + `
3129
+
3130
+ Resolve conflicts before merging.`
3131
+ }]
3132
+ };
3133
+ }
3134
+ return { content: [{ type: "text", text: "Unknown action." }] };
3135
+ }
3136
+ );
3137
+ server.registerTool(
3138
+ "chain-review",
3139
+ {
3140
+ title: "Chain Review",
3141
+ description: "Review process quality \u2014 run the coherence gate or manage comments on process versions. The gate checks: coherence score >= 70%, all links filled, commit message convention.",
3142
+ inputSchema: {
3143
+ action: z7.enum(["gate", "comment", "resolve-comment", "list-comments"]).describe("Action: run coherence gate, add a comment, resolve a comment, or list comments"),
3144
+ chainEntryId: z7.string().describe("The chain's entry ID"),
3145
+ commitMessage: z7.string().optional().describe("Commit message to lint (for gate action)"),
3146
+ versionNumber: z7.number().optional().describe("Version to comment on or list comments for"),
3147
+ linkId: z7.string().optional().describe("Link this comment targets (optional for comment)"),
3148
+ body: z7.string().optional().describe("Comment text (required for comment action)"),
3149
+ commentId: z7.string().optional().describe("Comment ID (required for resolve-comment)"),
3150
+ author: z7.string().optional().describe("Who is performing the action. Defaults to 'mcp'.")
3151
+ },
3152
+ annotations: { readOnlyHint: false }
3153
+ },
3154
+ async ({ action, chainEntryId, commitMessage, versionNumber, linkId, body, commentId, author }) => {
3155
+ if (action === "gate") {
3156
+ const gate = await mcpQuery("gitchain.runGate", { chainEntryId, commitMessage });
3157
+ const checkLines = gate.checks.map((c) => `- ${c.pass ? "PASS" : "FAIL"} **${c.name}**: ${c.detail}`).join("\n");
3158
+ const icon = gate.pass ? "PASS" : "BLOCKED";
3159
+ return {
3160
+ content: [{
3161
+ type: "text",
3162
+ text: `# Gate: ${icon}
3163
+
3164
+ - **Score:** ${gate.score}%
3165
+ - **Threshold:** ${gate.threshold}%
3166
+
3167
+ ## Checks
3168
+
3169
+ ${checkLines}`
3170
+ }]
3171
+ };
3172
+ }
3173
+ if (action === "comment") {
3174
+ if (!versionNumber) return { content: [{ type: "text", text: "A `versionNumber` is required." }] };
3175
+ if (!body) return { content: [{ type: "text", text: "A `body` is required." }] };
3176
+ const result = await mcpMutation("gitchain.addComment", {
3177
+ chainEntryId,
3178
+ versionNumber,
3179
+ linkId,
3180
+ body,
3181
+ author
3182
+ });
3183
+ return {
3184
+ content: [{
3185
+ type: "text",
3186
+ text: `# Comment Added
3187
+
3188
+ - **Chain:** \`${result.chainEntryId}\`
3189
+ - **Version:** v${result.versionNumber}
3190
+ ` + (result.linkId ? `- **Link:** ${result.linkId}
3191
+ ` : "") + `- **Body:** ${body.substring(0, 200)}`
3192
+ }]
3193
+ };
3194
+ }
3195
+ if (action === "resolve-comment") {
3196
+ if (!commentId) return { content: [{ type: "text", text: "A `commentId` is required." }] };
3197
+ await mcpMutation("gitchain.resolveComment", { commentId });
3198
+ return { content: [{ type: "text", text: `Comment resolved.` }] };
3199
+ }
3200
+ if (action === "list-comments") {
3201
+ const comments = await mcpMutation("gitchain.listComments", {
3202
+ chainEntryId,
3203
+ versionNumber
3204
+ });
3205
+ if (comments.length === 0) {
3206
+ return { content: [{ type: "text", text: `No comments found for chain "${chainEntryId}"${versionNumber ? ` v${versionNumber}` : ""}.` }] };
3207
+ }
3208
+ const formatted = comments.map((c) => {
3209
+ const date = new Date(c.createdAt).toISOString().replace("T", " ").substring(0, 19);
3210
+ const resolved = c.resolved ? " (RESOLVED)" : "";
3211
+ const link = c.linkId ? ` [${c.linkId}]` : "";
3212
+ return `- **v${c.version}${link}** ${date} by ${c.author}${resolved}: ${c.body.substring(0, 150)}`;
3213
+ }).join("\n");
3214
+ const unresolvedCount = comments.filter((c) => !c.resolved).length;
3215
+ return {
3216
+ content: [{
3217
+ type: "text",
3218
+ text: `# Comments for ${chainEntryId} (${comments.length}, ${unresolvedCount} unresolved)
3219
+
3220
+ ${formatted}`
3221
+ }]
3222
+ };
3223
+ }
3224
+ return { content: [{ type: "text", text: "Unknown action." }] };
3225
+ }
3226
+ );
3227
+ }
3228
+
3229
+ // src/tools/maps.ts
3230
+ import { z as z8 } from "zod";
3231
+ function slotSummary(slots) {
3232
+ return Object.entries(slots).map(([id, refs]) => {
3233
+ if (!refs || refs.length === 0) return ` - **${id}**: (empty)`;
3234
+ const items = refs.map((r) => {
3235
+ const name = r.ingredientName ?? r.label ?? r.entryId;
3236
+ const version = r.pinnedVersion ? ` v${r.pinnedVersion}` : "";
3237
+ const status = r.ingredientStatus ? ` [${r.ingredientStatus}]` : "";
3238
+ return `${name}${version}${status}`;
3239
+ }).join(", ");
3240
+ return ` - **${id}**: ${items}`;
3241
+ }).join("\n");
3242
+ }
3243
+ function registerMapTools(server) {
3244
+ server.registerTool(
3245
+ "map",
3246
+ {
3247
+ title: "Map",
3248
+ description: "Manage composed framework maps \u2014 create, get, or list. Maps assemble ingredient entries into framework slots (e.g. Lean Canvas). Use map-slot to add/remove ingredients, map-version to commit and view history.",
3249
+ inputSchema: {
3250
+ action: z8.enum(["create", "get", "list"]).describe("Action: create a map, get map details, or list all maps"),
3251
+ mapEntryId: z8.string().optional().describe("Map entry ID (for get)"),
3252
+ title: z8.string().optional().describe("Map title (for create)"),
3253
+ templateId: z8.string().optional().default("lean-canvas").describe("Template slug for create: 'lean-canvas' or any composed template"),
3254
+ description: z8.string().optional().describe("Description (for create)"),
3255
+ slotIds: z8.array(z8.string()).optional().describe("Slot IDs to initialize (for create; auto-populated from template if omitted)"),
3256
+ status: z8.string().optional().describe("Filter by status for list")
3257
+ }
3258
+ },
3259
+ async ({ action, mapEntryId, title, templateId, description, slotIds, status }) => {
3260
+ if (action === "create") {
3261
+ if (!title) {
3262
+ return {
3263
+ content: [
3264
+ { type: "text", text: "A `title` is required to create a map." }
3265
+ ]
3266
+ };
3267
+ }
3268
+ const result = await mcpMutation(
3269
+ "maps.createMap",
3270
+ { title, templateId, description, slotIds }
3271
+ );
3272
+ const wsCtx = await getWorkspaceContext();
3273
+ return {
3274
+ content: [
3275
+ {
3276
+ type: "text",
3277
+ text: `# Map Created
3278
+
3279
+ - **Entry ID:** \`${result.entryId}\`
3280
+ - **Title:** ${title}
3281
+ - **Template:** ${templateId}
3282
+ - **Status:** draft
3283
+ - **Workspace:** ${wsCtx.workspaceSlug} (${wsCtx.workspaceId})
3284
+
3285
+ Use \`map-slot action=add mapEntryId="${result.entryId}" slotId="problem" ingredientEntryId="..."\` to start filling slots.`
3286
+ }
3287
+ ]
3288
+ };
3289
+ }
3290
+ if (action === "get") {
3291
+ if (!mapEntryId) {
3292
+ return {
3293
+ content: [{ type: "text", text: "A `mapEntryId` is required." }]
3294
+ };
3295
+ }
3296
+ const map = await mcpQuery("maps.getMap", { mapEntryId });
3297
+ if (!map) {
3298
+ return {
3299
+ content: [{ type: "text", text: `Map "${mapEntryId}" not found.` }]
3300
+ };
3301
+ }
3302
+ return {
3303
+ content: [
3304
+ {
3305
+ type: "text",
3306
+ text: `# ${map.name}
3307
+
3308
+ - **Entry ID:** \`${map.entryId}\`
3309
+ - **Template:** ${map.templateName}
3310
+ - **Status:** ${map.status}
3311
+ - **Version:** ${map.currentVersion}
3312
+ - **Completion:** ${map.completionScore}% (${map.filledSlots}/${map.totalSlots} slots)
3313
+
3314
+ ## Slots
3315
+
3316
+ ` + slotSummary(map.slots)
3317
+ }
3318
+ ]
3319
+ };
3320
+ }
3321
+ if (action === "list") {
3322
+ const maps = await mcpQuery("maps.listMaps", {
3323
+ templateId: templateId !== "lean-canvas" ? templateId : void 0,
3324
+ status
3325
+ });
3326
+ if (!maps || maps.length === 0) {
3327
+ return {
3328
+ content: [
3329
+ {
3330
+ type: "text",
3331
+ text: 'No maps found. Create one with `map action=create title="My Canvas"`.'
3332
+ }
3333
+ ]
3334
+ };
3335
+ }
3336
+ const lines = maps.map(
3337
+ (m) => `- **${m.name}** (\`${m.entryId}\`) \u2014 ${m.templateId}, ${m.completionScore}% complete, v${m.currentVersion}`
3338
+ );
3339
+ return {
3340
+ content: [
3341
+ {
3342
+ type: "text",
3343
+ text: `# Maps (${maps.length})
3344
+
3345
+ ${lines.join("\n")}`
3346
+ }
3347
+ ]
3348
+ };
3349
+ }
3350
+ return {
3351
+ content: [{ type: "text", text: `Unknown action: ${action}` }]
3352
+ };
3353
+ }
3354
+ );
3355
+ server.registerTool(
3356
+ "map-slot",
3357
+ {
3358
+ title: "Map Slot",
3359
+ description: "Add, remove, or replace ingredient references in a map's slots. Ingredients are entries from any collection that fill a framework position.",
3360
+ inputSchema: {
3361
+ action: z8.enum(["add", "remove", "replace", "list"]).describe("Action: add/remove/replace an ingredient in a slot, or list slot contents"),
3362
+ mapEntryId: z8.string().describe("Map entry ID"),
3363
+ slotId: z8.string().optional().describe("Slot ID (e.g. 'problem', 'customer-segments')"),
3364
+ ingredientEntryId: z8.string().optional().describe("Ingredient entry ID to add/remove"),
3365
+ newIngredientEntryId: z8.string().optional().describe("New ingredient entry ID (for replace)"),
3366
+ label: z8.string().optional().describe("Display label override"),
3367
+ author: z8.string().optional().describe("Who is performing the action")
3368
+ }
3369
+ },
3370
+ async ({
3371
+ action,
3372
+ mapEntryId,
3373
+ slotId,
3374
+ ingredientEntryId,
3375
+ newIngredientEntryId,
3376
+ label,
3377
+ author
3378
+ }) => {
3379
+ if (action === "list") {
3380
+ const map = await mcpQuery("maps.getMap", { mapEntryId });
3381
+ if (!map) {
3382
+ return {
3383
+ content: [{ type: "text", text: `Map "${mapEntryId}" not found.` }]
3384
+ };
3385
+ }
3386
+ if (slotId && map.slots[slotId]) {
3387
+ return {
3388
+ content: [
3389
+ {
3390
+ type: "text",
3391
+ text: `# Slot: ${slotId}
3392
+
3393
+ ` + (map.slots[slotId].length === 0 ? "(empty)" : map.slots[slotId].map(
3394
+ (r) => `- **${r.ingredientName ?? r.entryId}** (\`${r.entryId}\`)${r.pinnedVersion ? ` v${r.pinnedVersion}` : ""}`
3395
+ ).join("\n"))
3396
+ }
3397
+ ]
3398
+ };
3399
+ }
3400
+ return {
3401
+ content: [
3402
+ { type: "text", text: `## All Slots
3403
+
3404
+ ${slotSummary(map.slots)}` }
3405
+ ]
3406
+ };
3407
+ }
3408
+ if (action === "add") {
3409
+ if (!slotId || !ingredientEntryId) {
3410
+ return {
3411
+ content: [
3412
+ {
3413
+ type: "text",
3414
+ text: "Both `slotId` and `ingredientEntryId` are required for add."
3415
+ }
3416
+ ]
3417
+ };
3418
+ }
3419
+ await mcpMutation("maps.addToSlot", {
3420
+ mapEntryId,
3421
+ slotId,
3422
+ ingredientEntryId,
3423
+ label,
3424
+ author
3425
+ });
3426
+ return {
3427
+ content: [
3428
+ {
3429
+ type: "text",
3430
+ text: `Added \`${ingredientEntryId}\` to slot "${slotId}" on map \`${mapEntryId}\`.`
3431
+ }
3432
+ ]
3433
+ };
3434
+ }
3435
+ if (action === "remove") {
3436
+ if (!slotId || !ingredientEntryId) {
3437
+ return {
3438
+ content: [
3439
+ {
3440
+ type: "text",
3441
+ text: "Both `slotId` and `ingredientEntryId` are required for remove."
3442
+ }
3443
+ ]
3444
+ };
3445
+ }
3446
+ await mcpMutation("maps.removeFromSlot", {
3447
+ mapEntryId,
3448
+ slotId,
3449
+ ingredientEntryId,
3450
+ author
3451
+ });
3452
+ return {
3453
+ content: [
3454
+ {
3455
+ type: "text",
3456
+ text: `Removed \`${ingredientEntryId}\` from slot "${slotId}" on map \`${mapEntryId}\`.`
3457
+ }
3458
+ ]
3459
+ };
3460
+ }
3461
+ if (action === "replace") {
3462
+ if (!slotId || !ingredientEntryId || !newIngredientEntryId) {
3463
+ return {
3464
+ content: [
3465
+ {
3466
+ type: "text",
3467
+ text: "`slotId`, `ingredientEntryId`, and `newIngredientEntryId` are required for replace."
3468
+ }
3469
+ ]
3470
+ };
3471
+ }
3472
+ await mcpMutation("maps.replaceInSlot", {
3473
+ mapEntryId,
3474
+ slotId,
3475
+ oldIngredientEntryId: ingredientEntryId,
3476
+ newIngredientEntryId,
3477
+ label,
3478
+ author
3479
+ });
3480
+ return {
3481
+ content: [
3482
+ {
3483
+ type: "text",
3484
+ text: `Replaced \`${ingredientEntryId}\` with \`${newIngredientEntryId}\` in slot "${slotId}".`
3485
+ }
3486
+ ]
3487
+ };
3488
+ }
3489
+ return {
3490
+ content: [{ type: "text", text: `Unknown action: ${action}` }]
3491
+ };
3492
+ }
3493
+ );
3494
+ server.registerTool(
3495
+ "map-version",
3496
+ {
3497
+ title: "Map Version",
3498
+ description: "Commit a map (pins ingredient versions), list commit history, or view a specific commit.",
3499
+ inputSchema: {
3500
+ action: z8.enum(["commit", "list", "history"]).describe("Action: commit the map, list commits, or view commit history"),
3501
+ mapEntryId: z8.string().describe("Map entry ID"),
3502
+ commitMessage: z8.string().optional().describe("Commit message (for commit action)"),
3503
+ author: z8.string().optional().describe("Who is committing")
3504
+ }
3505
+ },
3506
+ async ({ action, mapEntryId, commitMessage, author }) => {
3507
+ if (action === "commit") {
3508
+ const result = await mcpMutation("maps.commitMap", {
3509
+ mapEntryId,
3510
+ commitMessage: commitMessage ?? "Map committed",
3511
+ author
3512
+ });
3513
+ return {
3514
+ content: [
3515
+ {
3516
+ type: "text",
3517
+ text: `# Map Committed
3518
+
3519
+ - **Version:** ${result.version}
3520
+ - **Completion:** ${result.completionScore}%
3521
+ - **Slots modified:** ${result.slotsModified.length > 0 ? result.slotsModified.join(", ") : "(none)"}
3522
+
3523
+ All ingredient references have been pinned at their current versions.`
3524
+ }
3525
+ ]
3526
+ };
3527
+ }
3528
+ if (action === "list" || action === "history") {
3529
+ const commits = await mcpQuery("maps.listMapCommits", {
3530
+ mapEntryId
3531
+ });
3532
+ if (!commits || commits.length === 0) {
3533
+ return {
3534
+ content: [
3535
+ {
3536
+ type: "text",
3537
+ text: `No commits yet for map \`${mapEntryId}\`. Use \`map-version action=commit\` to create the first snapshot.`
3538
+ }
3539
+ ]
3540
+ };
3541
+ }
3542
+ const lines = commits.map(
3543
+ (c) => `- **v${c.version}** (${new Date(c.createdAt).toLocaleDateString()}) \u2014 ${c.commitMessage ?? "(no message)"} \u2014 by ${c.author}` + (c.slotsModified.length > 0 ? `
3544
+ Slots changed: ${c.slotsModified.join(", ")}` : "")
3545
+ );
3546
+ return {
3547
+ content: [
3548
+ {
3549
+ type: "text",
3550
+ text: `# Map History: \`${mapEntryId}\` (${commits.length} commits)
3551
+
3552
+ ${lines.join("\n")}`
3553
+ }
3554
+ ]
3555
+ };
3556
+ }
3557
+ return {
3558
+ content: [{ type: "text", text: `Unknown action: ${action}` }]
3559
+ };
3560
+ }
3561
+ );
3562
+ server.registerTool(
3563
+ "map-suggest",
3564
+ {
3565
+ title: "Map Suggest",
3566
+ description: "Given a map with empty slots, search the Chain for ingredients that could fill them. Uses keyword search and collection matching based on the template's suggested collections.",
3567
+ inputSchema: {
3568
+ mapEntryId: z8.string().describe("Map entry ID to suggest ingredients for"),
3569
+ slotId: z8.string().optional().describe("Specific slot to find ingredients for (or all empty slots)"),
3570
+ query: z8.string().optional().describe("Optional search query to narrow ingredient suggestions")
3571
+ }
3572
+ },
3573
+ async ({ mapEntryId, slotId, query }) => {
3574
+ const map = await mcpQuery("maps.getMap", { mapEntryId });
3575
+ if (!map) {
3576
+ return {
3577
+ content: [{ type: "text", text: `Map "${mapEntryId}" not found.` }]
3578
+ };
3579
+ }
3580
+ const emptySlots = slotId ? [slotId].filter((id) => (map.slots[id]?.length ?? 0) === 0) : Object.entries(map.slots).filter(([, refs]) => refs.length === 0).map(([id]) => id);
3581
+ if (emptySlots.length === 0) {
3582
+ return {
3583
+ content: [
3584
+ {
3585
+ type: "text",
3586
+ text: slotId ? `Slot "${slotId}" already has ingredients.` : "All slots have ingredients. Nothing to suggest."
3587
+ }
3588
+ ]
3589
+ };
3590
+ }
3591
+ const slotDefs = map.slotDefs ?? [];
3592
+ const suggestions = [];
3593
+ for (const sid of emptySlots) {
3594
+ const def = slotDefs.find((s) => s.id === sid);
3595
+ const searchQuery = query ?? def?.label ?? sid;
3596
+ const results = await mcpQuery("knowledge.search", {
3597
+ query: searchQuery,
3598
+ limit: 5
3599
+ });
3600
+ if (results && results.length > 0) {
3601
+ const items = results.map(
3602
+ (r) => ` - **${r.name}** (\`${r.entryId}\`, ${r.collectionName ?? "unknown"}) \u2014 ${r.status}`
3603
+ );
3604
+ suggestions.push(
3605
+ `### ${def?.label ?? sid}
3606
+ ${def?.description ?? ""}
3607
+
3608
+ ${items.join("\n")}`
3609
+ );
3610
+ } else {
3611
+ suggestions.push(
3612
+ `### ${def?.label ?? sid}
3613
+ _No matching entries found. Create ingredients first._`
3614
+ );
3615
+ }
3616
+ }
3617
+ return {
3618
+ content: [
3619
+ {
3620
+ type: "text",
3621
+ text: `# Ingredient Suggestions for "${map.name}"
3622
+
3623
+ ` + suggestions.join("\n\n") + `
3624
+
3625
+ Use \`map-slot action=add mapEntryId="${mapEntryId}" slotId="..." ingredientEntryId="..."\` to add ingredients.`
3626
+ }
3627
+ ]
3628
+ };
3629
+ }
3630
+ );
3631
+ }
3632
+
3633
+ // src/tools/start.ts
3634
+ import { z as z9 } from "zod";
3635
+
3636
+ // src/presets/collections.ts
3637
+ var COLLECTION_PRESETS = [
3638
+ {
3639
+ id: "software-product",
3640
+ name: "Software Product",
3641
+ description: "For teams building software products \u2014 glossary, features, architecture, tech debt, and API endpoints",
3642
+ collections: [
3643
+ { slug: "glossary", name: "Glossary", description: "Canonical terminology for the product domain", fields: [{ key: "canonical", label: "Canonical", type: "string", required: true, searchable: true }, { key: "category", label: "Category", type: "select", options: ["Platform & Architecture", "Knowledge Management", "AI & Developer Tools", "Governance & Process"] }, { key: "confusedWith", label: "Confused With", type: "array" }] },
3644
+ { slug: "features", name: "Features", description: "Product features and capabilities", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "scope", label: "Scope", type: "string" }, { key: "area", label: "Area", type: "string" }, { key: "status", label: "Status", type: "select", options: ["proposed", "in-progress", "shipped", "deprecated"] }] },
3645
+ { slug: "architecture", name: "Architecture", description: "System architecture layers and components", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "layer", label: "Layer", type: "string" }, { key: "dependencies", label: "Dependencies", type: "array" }] },
3646
+ { slug: "tech-debt", name: "Tech Debt", description: "Technical debt items to track and address", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "severity", label: "Severity", type: "select", options: ["low", "medium", "high", "critical"] }, { key: "area", label: "Area", type: "string" }, { key: "effort", label: "Effort", type: "select", options: ["small", "medium", "large"] }] },
3647
+ { slug: "api-endpoints", name: "API Endpoints", description: "REST/GraphQL endpoints and their contracts", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "method", label: "Method", type: "select", options: ["GET", "POST", "PUT", "PATCH", "DELETE"] }, { key: "path", label: "Path", type: "string" }, { key: "auth", label: "Auth Required", type: "select", options: ["none", "api-key", "bearer", "session"] }] },
3648
+ { slug: "decisions", name: "Decisions", description: "Significant decisions with rationale and context", fields: [{ key: "rationale", label: "Rationale", type: "string", required: true, searchable: true }, { key: "date", label: "Date", type: "string" }, { key: "decidedBy", label: "Decided By", type: "string" }, { key: "alternatives", label: "Alternatives", type: "string" }] },
3649
+ { slug: "tensions", name: "Tensions", description: "Friction points, pain points, and unmet needs", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "priority", label: "Priority", type: "select", options: ["low", "medium", "high", "critical"] }, { key: "severity", label: "Severity", type: "select", options: ["low", "medium", "high", "critical"] }] }
3650
+ ]
3651
+ },
3652
+ {
3653
+ id: "content-business",
3654
+ name: "Content Business",
3655
+ description: "For content creators, publishers, and media companies \u2014 topics, audience segments, content calendar, and brand voice",
3656
+ collections: [
3657
+ { slug: "glossary", name: "Glossary", description: "Industry terminology and brand language", fields: [{ key: "canonical", label: "Canonical", type: "string", required: true, searchable: true }, { key: "category", label: "Category", type: "string" }] },
3658
+ { slug: "topics", name: "Topics", description: "Content topics and themes", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "pillar", label: "Content Pillar", type: "string" }, { key: "stage", label: "Stage", type: "select", options: ["idea", "researching", "drafting", "published", "evergreen"] }] },
3659
+ { slug: "audience-segments", name: "Audience Segments", description: "Target reader/viewer personas", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "painPoints", label: "Pain Points", type: "string" }, { key: "channels", label: "Channels", type: "array" }] },
3660
+ { slug: "brand-voice", name: "Brand Voice", description: "Tone, style, and voice guidelines", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "doThis", label: "Do This", type: "string" }, { key: "notThis", label: "Not This", type: "string" }] },
3661
+ { slug: "decisions", name: "Decisions", description: "Editorial and strategic decisions", fields: [{ key: "rationale", label: "Rationale", type: "string", required: true, searchable: true }, { key: "date", label: "Date", type: "string" }, { key: "decidedBy", label: "Decided By", type: "string" }] },
3662
+ { slug: "tensions", name: "Tensions", description: "Content gaps, audience friction, and unmet needs", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "priority", label: "Priority", type: "select", options: ["low", "medium", "high", "critical"] }] }
3663
+ ]
3664
+ },
3665
+ {
3666
+ id: "agency",
3667
+ name: "Agency",
3668
+ description: "For agencies managing multiple clients \u2014 client profiles, project scopes, deliverables, and processes",
3669
+ collections: [
3670
+ { slug: "glossary", name: "Glossary", description: "Agency and client terminology", fields: [{ key: "canonical", label: "Canonical", type: "string", required: true, searchable: true }, { key: "category", label: "Category", type: "string" }] },
3671
+ { slug: "clients", name: "Clients", description: "Client profiles and relationship context", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "industry", label: "Industry", type: "string" }, { key: "contact", label: "Primary Contact", type: "string" }] },
3672
+ { slug: "deliverables", name: "Deliverables", description: "Standard deliverable types and templates", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "type", label: "Type", type: "string" }, { key: "estimatedHours", label: "Estimated Hours", type: "string" }] },
3673
+ { slug: "processes", name: "Processes", description: "Standard operating procedures and workflows", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "steps", label: "Steps", type: "string" }, { key: "owner", label: "Owner", type: "string" }] },
3674
+ { slug: "decisions", name: "Decisions", description: "Strategic and operational decisions", fields: [{ key: "rationale", label: "Rationale", type: "string", required: true, searchable: true }, { key: "date", label: "Date", type: "string" }] },
3675
+ { slug: "tensions", name: "Tensions", description: "Process bottlenecks and client friction", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "priority", label: "Priority", type: "select", options: ["low", "medium", "high", "critical"] }] }
3676
+ ]
3677
+ },
3678
+ {
3679
+ id: "saas-api",
3680
+ name: "SaaS API",
3681
+ description: "For API-first SaaS products \u2014 endpoints, schemas, rate limits, changelog, and integration guides",
3682
+ collections: [
3683
+ { slug: "glossary", name: "Glossary", description: "API and domain terminology", fields: [{ key: "canonical", label: "Canonical", type: "string", required: true, searchable: true }, { key: "category", label: "Category", type: "string" }] },
3684
+ { slug: "api-endpoints", name: "API Endpoints", description: "API routes and contracts", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "method", label: "Method", type: "select", options: ["GET", "POST", "PUT", "PATCH", "DELETE"] }, { key: "path", label: "Path", type: "string" }, { key: "auth", label: "Auth", type: "select", options: ["none", "api-key", "bearer", "oauth"] }] },
3685
+ { slug: "schemas", name: "Schemas", description: "Data models and API schemas", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "format", label: "Format", type: "select", options: ["json", "protobuf", "graphql", "openapi"] }, { key: "version", label: "Version", type: "string" }] },
3686
+ { slug: "rate-limits", name: "Rate Limits", description: "Rate limiting policies and tiers", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "tier", label: "Tier", type: "string" }, { key: "limit", label: "Limit", type: "string" }] },
3687
+ { slug: "changelog", name: "Changelog", description: "API version history and breaking changes", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "version", label: "Version", type: "string" }, { key: "date", label: "Date", type: "string" }, { key: "breaking", label: "Breaking", type: "select", options: ["yes", "no"] }] },
3688
+ { slug: "decisions", name: "Decisions", description: "API design decisions and rationale", fields: [{ key: "rationale", label: "Rationale", type: "string", required: true, searchable: true }, { key: "date", label: "Date", type: "string" }] }
3689
+ ]
3690
+ },
3691
+ {
3692
+ id: "general",
3693
+ name: "General",
3694
+ description: "A minimal starter set \u2014 glossary, decisions, and tensions. Add more collections as you need them.",
3695
+ collections: [
3696
+ { slug: "glossary", name: "Glossary", description: "Canonical terminology", fields: [{ key: "canonical", label: "Canonical", type: "string", required: true, searchable: true }, { key: "category", label: "Category", type: "string" }] },
3697
+ { slug: "decisions", name: "Decisions", description: "Decisions with rationale", fields: [{ key: "rationale", label: "Rationale", type: "string", required: true, searchable: true }, { key: "date", label: "Date", type: "string" }, { key: "decidedBy", label: "Decided By", type: "string" }] },
3698
+ { slug: "tensions", name: "Tensions", description: "Friction points and unmet needs", fields: [{ key: "description", label: "Description", type: "string", required: true, searchable: true }, { key: "priority", label: "Priority", type: "select", options: ["low", "medium", "high", "critical"] }] }
3699
+ ]
3700
+ }
3701
+ ];
3702
+ function getPreset(id) {
3703
+ return COLLECTION_PRESETS.find((p) => p.id === id);
3704
+ }
3705
+ function listPresets() {
3706
+ return COLLECTION_PRESETS.map((p) => ({
3707
+ id: p.id,
3708
+ name: p.name,
3709
+ description: p.description,
3710
+ collectionCount: p.collections.length
3711
+ }));
3712
+ }
3713
+
3714
+ // src/tools/start.ts
3715
+ function registerStartTools(server) {
3716
+ server.registerTool(
3717
+ "start",
3718
+ {
3719
+ title: "Start Product Brain",
3720
+ description: "The zero-friction entry point. Say 'start PB' to begin.\n\n- **Fresh workspace**: asks what you're building, seeds tailored collections, and gets you ready to capture knowledge immediately.\n- **Existing workspace**: returns readiness score, recent activity, open tensions, and suggested next actions (same as orient).\n\nUse this as your first call. Replaces the need to call orient, workspace-status, or health separately.",
3721
+ inputSchema: {
3722
+ preset: z9.string().optional().describe(
3723
+ "Collection preset ID to seed (e.g. 'software-product', 'content-business', 'agency', 'saas-api', 'general'). Only used for fresh workspaces. If omitted on a fresh workspace, returns the preset menu."
3724
+ )
3725
+ },
3726
+ annotations: { readOnlyHint: false }
3727
+ },
3728
+ async ({ preset }) => {
3729
+ const errors = [];
3730
+ const agentSessionId = getAgentSessionId();
3731
+ let wsCtx = null;
3732
+ try {
3733
+ wsCtx = await getWorkspaceContext();
3734
+ } catch (e) {
3735
+ errors.push(`Workspace: ${e.message}`);
3736
+ }
3737
+ if (!wsCtx) {
3738
+ return {
3739
+ content: [
3740
+ {
3741
+ type: "text",
3742
+ text: "# Could not connect to Product Brain\n\n" + (errors.length > 0 ? errors.map((e) => `- ${e}`).join("\n") : "Check your API key and CONVEX_SITE_URL.")
3743
+ }
3744
+ ]
3745
+ };
3746
+ }
3747
+ const isFresh = await detectFreshWorkspace();
3748
+ if (isFresh && !preset) {
3749
+ return { content: [{ type: "text", text: buildPresetMenu(wsCtx) }] };
3750
+ }
3751
+ if (isFresh && preset) {
3752
+ return { content: [{ type: "text", text: await seedPreset(wsCtx, preset, agentSessionId) }] };
3753
+ }
3754
+ return { content: [{ type: "text", text: await buildOrientResponse(wsCtx, agentSessionId, errors) }] };
3755
+ }
3756
+ );
3757
+ }
3758
+ async function detectFreshWorkspace() {
3759
+ try {
3760
+ const entries = await mcpQuery("chain.listEntries", {});
3761
+ const nonSystem = (entries ?? []).filter((e) => e.stratum !== "system");
3762
+ return nonSystem.length === 0;
3763
+ } catch {
3764
+ return false;
3765
+ }
3766
+ }
3767
+ function buildPresetMenu(wsCtx) {
3768
+ const presets = listPresets();
3769
+ const lines = [
3770
+ `# Welcome to ${wsCtx.workspaceName}`,
3771
+ "",
3772
+ "Your workspace is fresh \u2014 let's get it set up together.",
3773
+ "",
3774
+ "**Tell me: what are you building?** Describe it in a sentence or two and I'll help you pick the right structure. Or choose a preset to start from:",
3775
+ ""
3776
+ ];
3777
+ for (const p of presets) {
3778
+ lines.push(`- **${p.name}** (\`${p.id}\`) \u2014 ${p.description} (${p.collectionCount} collections)`);
3779
+ }
3780
+ lines.push(
3781
+ "",
3782
+ 'Call `start` again with your choice, e.g.: `start preset="software-product"`',
3783
+ "",
3784
+ "_These are starting points. You can add, remove, or customize collections at any time using `create-collection` and `update-collection`._"
3785
+ );
3786
+ return lines.join("\n");
3787
+ }
3788
+ async function seedPreset(wsCtx, presetId, agentSessionId) {
3789
+ const preset = getPreset(presetId);
3790
+ if (!preset) {
3791
+ return `Preset "${presetId}" not found.
3792
+
3793
+ Available presets: ${listPresets().map((p) => `\`${p.id}\``).join(", ")}`;
3794
+ }
3795
+ const seeded = [];
3796
+ const skipped = [];
3797
+ for (const col of preset.collections) {
3798
+ try {
3799
+ await mcpCall("chain.createCollection", {
3800
+ slug: col.slug,
3801
+ name: col.name,
3802
+ description: col.description,
3803
+ icon: col.icon,
3804
+ fields: col.fields,
3805
+ stratum: "working",
3806
+ createdBy: "preset:" + presetId
3807
+ });
3808
+ seeded.push(col.name);
3809
+ } catch {
3810
+ skipped.push(`${col.name} (already exists)`);
3811
+ }
3812
+ }
3813
+ if (agentSessionId) {
3814
+ try {
3815
+ await mcpCall("agent.markOriented", { sessionId: agentSessionId });
3816
+ setSessionOriented(true);
3817
+ } catch {
3818
+ }
3819
+ }
3820
+ const lines = [
3821
+ `# ${wsCtx.workspaceName} is ready`,
3822
+ "",
3823
+ `Seeded **${preset.name}** preset with ${seeded.length} collection${seeded.length === 1 ? "" : "s"}:`,
3824
+ ""
3825
+ ];
3826
+ for (const name of seeded) {
3827
+ lines.push(`- ${name}`);
3828
+ }
3829
+ if (skipped.length > 0) {
3830
+ lines.push("", "Skipped (already exist):");
3831
+ for (const name of skipped) {
3832
+ lines.push(`- ${name}`);
3833
+ }
3834
+ }
3835
+ lines.push(
3836
+ "",
3837
+ "## Let's activate your workspace",
3838
+ "",
3839
+ "I'll help you load knowledge step by step. Everything stays as a draft until you confirm it.",
3840
+ "",
3841
+ "**First:** Tell me what you're building in one or two sentences. I'll capture it as your product vision.",
3842
+ "",
3843
+ `_After that, we'll add a few key terms to your glossary and any important decisions. Each entry is a draft \u2014 say "commit" or "looks good" when you're happy with it, and I'll promote it to the Chain (SSOT)._`,
3844
+ "",
3845
+ "You can also customize your structure anytime: `create-collection`, `update-collection`, or `list-collections`.",
3846
+ "",
3847
+ "---",
3848
+ "Orientation complete. Write tools are available."
3849
+ );
3850
+ return lines.join("\n");
3851
+ }
3852
+ function computeWorkspaceAge(createdAt) {
3853
+ if (!createdAt) return { ageDays: 0, isNeglected: false };
3854
+ const ageDays = Math.floor((Date.now() - createdAt) / (1e3 * 60 * 60 * 24));
3855
+ return { ageDays, isNeglected: ageDays >= 30 };
3856
+ }
3857
+ function pickNextAction(gaps, openTensions, priorSessions) {
3858
+ if (gaps.length === 0 && openTensions.length === 0) return null;
3859
+ if (gaps.length > 0) {
3860
+ const gap = gaps[0];
3861
+ const ctaMap = {
3862
+ "strategy-vision": "Tell me what you're building \u2014 your vision, mission, and north star \u2014 and I'll capture it.",
3863
+ "architecture-layers": "Describe your architecture in a few sentences and I'll capture it.",
3864
+ "glossary-foundation": "What are the key terms your team uses? Tell me a few and I'll add them to the glossary.",
3865
+ "decisions-documented": "What's a recent significant decision your team made? I'll document it with the rationale.",
3866
+ "tensions-tracked": "What's a friction point or pain point you're dealing with? I'll capture it as a tension."
3867
+ };
3868
+ const cta = ctaMap[gap.id] ?? `Tell me about your ${gap.label.toLowerCase()} and I'll capture it.`;
3869
+ return { action: gap.label, cta };
3870
+ }
3871
+ if (openTensions.length > 0) {
3872
+ const t = openTensions[0];
3873
+ return {
3874
+ action: `Open tension: ${t.name}`,
3875
+ cta: "Want to discuss this tension or capture a decision about it?"
3876
+ };
3877
+ }
3878
+ return null;
3879
+ }
3880
+ async function buildOrientResponse(wsCtx, agentSessionId, errors) {
3881
+ const wsFullCtx = await getWorkspaceContext();
3882
+ const { ageDays, isNeglected } = computeWorkspaceAge(wsFullCtx.createdAt);
3883
+ let priorSessions = [];
3884
+ try {
3885
+ priorSessions = await mcpQuery("agent.recentSessions", { limit: 3 });
3886
+ } catch {
3887
+ }
3888
+ let openTensions = [];
3889
+ try {
3890
+ const tensions = await mcpQuery("chain.listEntries", { collectionSlug: "tensions" });
3891
+ openTensions = (tensions ?? []).filter((e) => e.status === "draft");
3892
+ } catch {
3893
+ }
3894
+ let readiness = null;
3895
+ try {
3896
+ readiness = await mcpQuery("chain.workspaceReadiness");
3897
+ } catch (e) {
3898
+ errors.push(`Readiness: ${e.message}`);
3899
+ }
3900
+ const lines = [];
3901
+ const isLowReadiness = readiness && readiness.score < 50;
3902
+ const isHighReadiness = readiness && readiness.score >= 50;
3903
+ lines.push(`# ${wsCtx.workspaceName}`);
3904
+ lines.push(`_Workspace \`${wsCtx.workspaceSlug}\` \u2014 Product Brain is healthy._`);
3905
+ lines.push("");
3906
+ if (isLowReadiness && isNeglected) {
3907
+ lines.push(`Your workspace has been around for ${ageDays} days but is only ${readiness.score}% ready.`);
3908
+ lines.push("Let's close the gaps \u2014 or if the current structure doesn't fit, we can reshape it.");
3909
+ lines.push("");
3910
+ } else if (isLowReadiness) {
3911
+ lines.push(`Readiness: ${readiness.score}%. Let's get your workspace active.`);
3912
+ lines.push("");
3913
+ }
3914
+ if (isLowReadiness) {
3915
+ const nextAction = pickNextAction(readiness.gaps ?? [], openTensions, priorSessions);
3916
+ if (nextAction) {
3917
+ lines.push("## Recommended next step");
3918
+ lines.push(`**${nextAction.action}**`);
3919
+ lines.push("");
3920
+ lines.push(nextAction.cta);
3921
+ lines.push("");
3922
+ lines.push('_Everything stays as a draft until you confirm. Say "commit" or "looks good" to promote to the Chain._');
3923
+ lines.push("");
3924
+ const remainingGaps = (readiness.gaps ?? []).length - 1;
3925
+ if (remainingGaps > 0 || openTensions.length > 0) {
3926
+ lines.push(`_${remainingGaps > 0 ? `${remainingGaps} more gap${remainingGaps === 1 ? "" : "s"}` : ""}${remainingGaps > 0 && openTensions.length > 0 ? " and " : ""}${openTensions.length > 0 ? `${openTensions.length} open tension${openTensions.length === 1 ? "" : "s"}` : ""} \u2014 ask "show full status" for details._`);
3927
+ lines.push("");
3928
+ }
3929
+ }
3930
+ lines.push("_Need a collection that doesn't exist yet (e.g. audiences, personas, metrics)?_");
3931
+ lines.push("_Use `create-collection` to add it, or ask me to propose collections for your domain._");
3932
+ lines.push("");
3933
+ } else if (isHighReadiness) {
3934
+ if (readiness) {
3935
+ lines.push(`Readiness: ${readiness.score}% (${readiness.passedChecks}/${readiness.totalChecks}).`);
3936
+ }
3937
+ const plannedWork = await queryPlannedWork();
3938
+ if (hasPlannedWork(plannedWork)) {
3939
+ lines.push("");
3940
+ lines.push(...buildPlannedWorkSection(plannedWork, priorSessions));
3941
+ } else {
3942
+ const briefingItems = [];
3943
+ if (priorSessions.length > 0) {
3944
+ const last = priorSessions[0];
3945
+ const date = new Date(last.startedAt).toISOString().split("T")[0];
3946
+ const created = Array.isArray(last.entriesCreated) ? last.entriesCreated.length : last.entriesCreated ?? 0;
3947
+ const modified = Array.isArray(last.entriesModified) ? last.entriesModified.length : last.entriesModified ?? 0;
3948
+ briefingItems.push(`**Last session** (${date}): ${created} created, ${modified} modified`);
3949
+ }
3950
+ if (readiness?.gaps?.length > 0) {
3951
+ briefingItems.push(`**${readiness.gaps.length} gap${readiness.gaps.length === 1 ? "" : "s"}** remaining`);
3952
+ }
3953
+ if (briefingItems.length > 0) {
3954
+ lines.push("");
3955
+ lines.push("## Briefing");
3956
+ for (const item of briefingItems) {
3957
+ lines.push(`- ${item}`);
3958
+ }
3959
+ lines.push("");
3960
+ }
3961
+ }
3962
+ lines.push("What would you like to work on?");
3963
+ lines.push("");
3964
+ }
3965
+ if (errors.length > 0) {
3966
+ lines.push("## Errors");
3967
+ for (const err of errors) lines.push(`- ${err}`);
3968
+ lines.push("");
3969
+ }
3970
+ if (agentSessionId) {
3971
+ try {
3972
+ await mcpCall("agent.markOriented", { sessionId: agentSessionId });
3973
+ setSessionOriented(true);
3974
+ lines.push("---");
3975
+ lines.push(
3976
+ `Orientation complete. Session ${agentSessionId}. Write tools available.`
3977
+ );
3978
+ } catch {
3979
+ lines.push("---");
3980
+ lines.push("_Warning: Could not mark session as oriented. Write tools may be restricted._");
3981
+ }
3982
+ } else {
3983
+ lines.push("---");
3984
+ lines.push("_No active agent session. Call `agent-start` to begin a tracked session._");
3985
+ }
3986
+ return lines.join("\n");
3987
+ }
3988
+
3989
+ // src/resources/index.ts
3990
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3991
+ function formatEntryMarkdown(entry) {
3992
+ const id = entry.entryId ? `${entry.entryId}: ` : "";
3993
+ const lines = [`## ${id}${entry.name} [${entry.status}]`];
3994
+ if (entry.data && typeof entry.data === "object") {
3995
+ for (const [key, val] of Object.entries(entry.data)) {
3996
+ if (val && key !== "rawData") {
3997
+ lines.push(`**${key}**: ${typeof val === "string" ? val : JSON.stringify(val)}`);
3998
+ }
3999
+ }
4000
+ }
4001
+ return lines.join("\n");
4002
+ }
4003
+ function buildOrientationMarkdown(collections, trackingEvents, standards, businessRules) {
4004
+ const sections = ["# Product Brain \u2014 Orientation"];
4005
+ sections.push(
4006
+ "## Core Product Architecture\nThe Chain is a versioned, connected, compounding knowledge base \u2014 the SSOT for a product team.\nEverything on the Chain is one of **three primitives**:\n\n- **Entry** (atom) \u2014 a discrete knowledge unit: target audience, business rule, glossary term, metric, tension. Lives in a collection.\n- **Process** (authored) \u2014 a narrative chain with named links (Strategy Coherence, IDM Proposal). Content is inline text. Collection: `chains`.\n- **Map** (composed) \u2014 a framework assembled by reference (Lean Canvas, Empathy Map). Slots point to ingredient entries. Collection: `maps`.\n\nEntries are atoms. Processes author narrative from atoms. Maps compose frameworks from atoms.\nAll three share the same versioning, branching, gating, and relation system.\n\nThe Chain compounds: each new relation makes entries discoverable from more starting points \u2192 gather-context returns richer results \u2192 AI processes have more context \u2192 quality scores improve \u2192 next commit is smarter than the last."
4007
+ );
4008
+ sections.push(
4009
+ "## Architecture\n```\nCursor (stdio) \u2192 MCP Server (mcp-server/src/index.ts)\n \u2192 POST /api/mcp with Bearer token\n \u2192 Convex HTTP Action (convex/http.ts)\n \u2192 internalQuery / internalMutation (convex/mcpKnowledge.ts)\n \u2192 Convex DB (workspace-scoped)\n```\nSecurity: API key auth on every request, workspace-scoped data, internal functions blocked from external clients.\nKey files: `packages/mcp-server/src/client.ts` (HTTP client + audit), `convex/schema.ts` (schema)."
4010
+ );
4011
+ if (collections) {
4012
+ const collList = collections.map((c) => {
4013
+ const prefix = c.icon ? `${c.icon} ` : "";
4014
+ return `- ${prefix}**${c.name}** (\`${c.slug}\`) \u2014 ${c.description || "no description"}`;
4015
+ }).join("\n");
4016
+ sections.push(
4017
+ `## Data Model (${collections.length} collections)
4018
+ Unified entries model: collections define field schemas, entries hold data in a flexible \`data\` field.
4019
+ The \`data\` field is polymorphic: plain fields for generic entries, \`ChainData\` (chainTypeId + links) for processes, \`MapData\` (templateId + slots) for maps.
4020
+ Tags for filtering (e.g. \`severity:high\`), relations via \`entryRelations\`, versions via \`entryVersions\`, labels via \`labels\` + \`entryLabels\`.
4021
+
4022
+ ` + collList + "\n\nUse `list-collections` for field schemas, `get-entry` for full records."
4023
+ );
4024
+ } else {
4025
+ sections.push(
4026
+ "## Data Model\nCould not load collections \u2014 use `list-collections` to browse manually."
4027
+ );
4028
+ }
4029
+ const rulesCount = businessRules ? `${businessRules.length} entries` : "not loaded \u2014 collection may not exist yet";
4030
+ sections.push(
4031
+ `## Business Rules
4032
+ Collection: \`business-rules\` (${rulesCount}).
4033
+ Find rules: \`search\` for text search, \`list-entries collection=business-rules\` to browse.
4034
+ Check compliance: use the \`review-against-rules\` prompt (pass a domain).
4035
+ Draft a new rule: use the \`draft-rule-from-context\` prompt.`
4036
+ );
4037
+ const eventsCount = trackingEvents ? `${trackingEvents.length} events` : "not loaded \u2014 collection may not exist yet";
4038
+ const conventionNote = standards ? "Naming convention: `object_action` in snake_case with past-tense verbs (from standards collection)." : "Naming convention: `object_action` in snake_case with past-tense verbs.";
4039
+ sections.push(
4040
+ `## Analytics & Tracking
4041
+ Event catalog: \`tracking-events\` collection (${eventsCount}).
4042
+ ${conventionNote}
4043
+ Implementation: \`src/lib/analytics.ts\`. Workspace-scoped events MUST use \`withWorkspaceGroup()\`.
4044
+ Browse: \`list-entries collection=tracking-events\`.`
4045
+ );
4046
+ sections.push(
4047
+ "## Knowledge Graph\nEntries are connected via typed relations (`entryRelations` table). Relations are bidirectional and collection-agnostic \u2014 any entry can link to any other entry.\n\n**Recommended relation types** (extensible \u2014 any string accepted):\n- `governs` \u2014 a rule constrains behavior of a feature\n- `defines_term_for` \u2014 a glossary term is canonical vocabulary for a feature/area\n- `belongs_to` \u2014 a feature belongs to a product area or parent concept\n- `informs` \u2014 a decision or insight informs a feature\n- `fills_slot` \u2014 an ingredient entry fills a slot in a map\n- `surfaces_tension_in` \u2014 a tension exists within a feature area\n- `related_to`, `depends_on`, `replaces`, `conflicts_with`, `references`, `confused_with`\n\nEach relation type is defined as a glossary entry (prefix `GT-REL-*`) to prevent terminology drift.\n\n**Tools:**\n- `gather-context` \u2014 get the full context around any entry (multi-hop graph traversal)\n- `suggest-links` \u2014 discover potential connections for an entry\n- `relate-entries` \u2014 create a typed link between two entries\n- `find-related` \u2014 list direct relations for an entry\n\n**Convention:** When creating or updating entries in governed collections, always use `suggest-links` to discover and create relevant relations."
4048
+ );
4049
+ sections.push(
4050
+ "## Creating Knowledge\n**Entries:** Use `capture` as the primary tool for creating new entries. It handles the full workflow in one call:\n1. Creates the entry with collection-aware defaults (auto-fills dates, infers domains, sets priority)\n2. Auto-links related entries from across the chain (up to 5 confident matches)\n3. Returns a quality scorecard (X/10) with actionable improvement suggestions\n\n**Smart profiles** exist for: `tensions`, `business-rules`, `glossary`, `decisions`, `features`, `audiences`, `strategy`, `standards`, `maps`, `chains`, `tracking-events`.\nAll other collections use the `ENT-{random}` fallback profile.\n\n**Processes:** Use `chain action=create` with a template (e.g., `strategy-coherence`, `idm-proposal`). Fill links with `chain action=edit`.\n\n**Maps:** Use `map action=create` with a template (e.g., `lean-canvas`). Fill slots with `map-slot action=add`. Commit with `map-version action=commit`.\nNote: committing a map version creates a snapshot but does NOT change the entry status. To activate: `update-entry entryId=MAP-xxx status=active autoPublish=true`.\nUse `map-suggest` to discover ingredients for empty slots.\n\n**Prompts** (invoke via MCP prompt protocol):\n- `name-check` \u2014 verify a name against glossary conventions\n- `draft-decision-record` \u2014 scaffold a decision entry\n- `review-against-rules` \u2014 check work against business rules for a domain\n- `draft-rule-from-context` \u2014 create a new business rule from context\n- `run-workflow` \u2014 run a structured ceremony (e.g., retrospective)\n\nUse `quality-check` to score existing entries retroactively.\nUse `update-entry` for post-creation adjustments (status changes, field updates, deprecation)."
4051
+ );
4052
+ sections.push(
4053
+ "## Where to Go Next\n- **Create entry** \u2192 `capture` tool (auto-links + quality score in one call)\n- **Full context** \u2192 `gather-context` tool (by entry ID or task description)\n- **Discover links** \u2192 `suggest-links` tool\n- **Quality audit** \u2192 `quality-check` tool\n- **Terminology** \u2192 `name-check` prompt or `productbrain://terminology` resource\n- **Schema details** \u2192 `productbrain://collections` resource or `list-collections` tool\n- **Labels** \u2192 `productbrain://labels` resource or `labels` tool\n- **Any collection** \u2192 `productbrain://{slug}/entries` resource\n- **Log a decision** \u2192 `draft-decision-record` prompt\n- **Build a map** \u2192 `map action=create` + `map-slot action=add` + `map-version action=commit`\n- **Architecture map** \u2192 `architecture action=show` tool (layered system visualization)\n- **Explore a layer** \u2192 `architecture action=explore` tool (drill into Auth, Core, Features, etc.)\n- **Growth funnel** \u2192 `productbrain://growth-funnel` resource or `list-entries collection=strategy status=draft`\n- **Audiences** \u2192 `productbrain://audiences/entries` resource or `list-entries collection=audiences`\n- **Session identity** \u2192 `whoami` tool (workspace + auth context)\n- **Health check** \u2192 `health` tool\n- **Debug MCP calls** \u2192 `mcp-audit` tool"
4054
+ );
4055
+ return sections.join("\n\n---\n\n");
4056
+ }
4057
+ function registerResources(server) {
4058
+ server.resource(
4059
+ "chain-orientation",
4060
+ "productbrain://orientation",
4061
+ async (uri) => {
4062
+ const [collectionsResult, eventsResult, standardsResult, rulesResult] = await Promise.allSettled([
4063
+ mcpQuery("chain.listCollections"),
4064
+ mcpQuery("chain.listEntries", { collectionSlug: "tracking-events" }),
4065
+ mcpQuery("chain.listEntries", { collectionSlug: "standards" }),
4066
+ mcpQuery("chain.listEntries", { collectionSlug: "business-rules" })
4067
+ ]);
4068
+ const collections = collectionsResult.status === "fulfilled" ? collectionsResult.value : null;
4069
+ const trackingEvents = eventsResult.status === "fulfilled" ? eventsResult.value : null;
4070
+ const standards = standardsResult.status === "fulfilled" ? standardsResult.value : null;
4071
+ const businessRules = rulesResult.status === "fulfilled" ? rulesResult.value : null;
4072
+ return {
4073
+ contents: [{
4074
+ uri: uri.href,
4075
+ text: buildOrientationMarkdown(collections, trackingEvents, standards, businessRules),
4076
+ mimeType: "text/markdown"
4077
+ }]
4078
+ };
4079
+ }
4080
+ );
4081
+ server.resource(
4082
+ "chain-terminology",
4083
+ "productbrain://terminology",
4084
+ async (uri) => {
4085
+ const [glossaryResult, standardsResult] = await Promise.allSettled([
4086
+ mcpQuery("chain.listEntries", { collectionSlug: "glossary" }),
4087
+ mcpQuery("chain.listEntries", { collectionSlug: "standards" })
4088
+ ]);
4089
+ const lines = ["# Product Brain \u2014 Terminology"];
4090
+ if (glossaryResult.status === "fulfilled") {
4091
+ if (glossaryResult.value.length > 0) {
4092
+ const terms = glossaryResult.value.map((t) => `- **${t.name}** (${t.entryId ?? "\u2014"}) [${t.status}]: ${t.data?.canonical ?? t.data?.description ?? ""}`).join("\n");
4093
+ lines.push(`## Glossary (${glossaryResult.value.length} terms)
4094
+
4095
+ ${terms}`);
4096
+ } else {
4097
+ lines.push("## Glossary\n\nNo glossary terms yet. Use `capture` with collection `glossary` to add terms.");
4098
+ }
4099
+ } else {
4100
+ lines.push("## Glossary\n\nCould not load glossary \u2014 use `list-entries collection=glossary` to browse manually.");
4101
+ }
4102
+ if (standardsResult.status === "fulfilled") {
4103
+ if (standardsResult.value.length > 0) {
4104
+ const stds = standardsResult.value.map((s) => `- **${s.name}** (${s.entryId ?? "\u2014"}) [${s.status}]: ${s.data?.description ?? ""}`).join("\n");
4105
+ lines.push(`## Standards (${standardsResult.value.length} entries)
4106
+
4107
+ ${stds}`);
4108
+ } else {
4109
+ lines.push("## Standards\n\nNo standards yet. Use `capture` with collection `standards` to add standards.");
4110
+ }
4111
+ } else {
4112
+ lines.push("## Standards\n\nCould not load standards \u2014 use `list-entries collection=standards` to browse manually.");
4113
+ }
4114
+ return {
4115
+ contents: [{ uri: uri.href, text: lines.join("\n\n---\n\n"), mimeType: "text/markdown" }]
4116
+ };
4117
+ }
4118
+ );
4119
+ server.resource(
4120
+ "chain-collections",
4121
+ "productbrain://collections",
4122
+ async (uri) => {
4123
+ const collections = await mcpQuery("chain.listCollections");
4124
+ if (collections.length === 0) {
4125
+ return { contents: [{ uri: uri.href, text: "No collections in this workspace.", mimeType: "text/markdown" }] };
4126
+ }
4127
+ const formatted = collections.map((c) => {
4128
+ const fieldList = c.fields.map((f) => ` - \`${f.key}\` (${f.type}${f.required ? ", required" : ""}${f.searchable ? ", searchable" : ""})`).join("\n");
4129
+ return `## ${c.icon ?? ""} ${c.name} (\`${c.slug}\`)
4130
+ ${c.description || ""}
4131
+
4132
+ **Fields:**
4133
+ ${fieldList}`;
4134
+ }).join("\n\n---\n\n");
4135
+ return {
4136
+ contents: [{ uri: uri.href, text: `# Knowledge Collections (${collections.length})
4137
+
4138
+ ${formatted}`, mimeType: "text/markdown" }]
4139
+ };
4140
+ }
4141
+ );
4142
+ server.resource(
4143
+ "chain-collection-entries",
4144
+ new ResourceTemplate("productbrain://{slug}/entries", {
4145
+ list: async () => {
4146
+ const collections = await mcpQuery("chain.listCollections");
4147
+ return {
4148
+ resources: collections.map((c) => ({
4149
+ uri: `productbrain://${c.slug}/entries`,
4150
+ name: `${c.icon ?? ""} ${c.name}`.trim()
4151
+ }))
4152
+ };
4153
+ }
4154
+ }),
4155
+ async (uri, { slug }) => {
4156
+ const entries = await mcpQuery("chain.listEntries", { collectionSlug: slug });
4157
+ const formatted = entries.map(formatEntryMarkdown).join("\n\n---\n\n");
4158
+ return {
4159
+ contents: [{
4160
+ uri: uri.href,
4161
+ text: formatted || "No entries in this collection.",
4162
+ mimeType: "text/markdown"
4163
+ }]
4164
+ };
4165
+ }
4166
+ );
4167
+ server.resource(
4168
+ "chain-labels",
4169
+ "productbrain://labels",
4170
+ async (uri) => {
4171
+ const labels = await mcpQuery("chain.listLabels");
4172
+ if (labels.length === 0) {
4173
+ return { contents: [{ uri: uri.href, text: "No labels in this workspace.", mimeType: "text/markdown" }] };
4174
+ }
4175
+ const groups = labels.filter((l) => l.isGroup);
4176
+ const ungrouped = labels.filter((l) => !l.isGroup && !l.parentId);
4177
+ const children = (parentId) => labels.filter((l) => l.parentId === parentId);
4178
+ const lines = [];
4179
+ for (const group of groups) {
4180
+ lines.push(`## ${group.name}`);
4181
+ for (const child of children(group._id)) {
4182
+ lines.push(`- \`${child.slug}\` ${child.name}${child.color ? ` (${child.color})` : ""}`);
4183
+ }
4184
+ }
4185
+ if (ungrouped.length > 0) {
4186
+ lines.push("## Ungrouped");
4187
+ for (const l of ungrouped) {
4188
+ lines.push(`- \`${l.slug}\` ${l.name}${l.color ? ` (${l.color})` : ""}`);
4189
+ }
4190
+ }
4191
+ return {
4192
+ contents: [{ uri: uri.href, text: `# Workspace Labels (${labels.length})
4193
+
4194
+ ${lines.join("\n")}`, mimeType: "text/markdown" }]
4195
+ };
4196
+ }
4197
+ );
4198
+ }
4199
+
4200
+ // src/prompts/index.ts
4201
+ import { z as z10 } from "zod";
4202
+ function registerPrompts(server) {
4203
+ server.prompt(
4204
+ "review-against-rules",
4205
+ "Review code or a design decision against all business rules for a given domain. Fetches the rules and asks you to do a structured compliance review.",
4206
+ { domain: z10.string().describe("Business rule domain (e.g. 'Identity & Access', 'Governance & Decision-Making')") },
4207
+ async ({ domain }) => {
4208
+ const entries = await mcpQuery("chain.listEntries", { collectionSlug: "business-rules" });
4209
+ const rules = entries.filter((e) => e.data?.domain === domain);
4210
+ if (rules.length === 0) {
4211
+ return {
4212
+ messages: [
4213
+ {
4214
+ role: "user",
4215
+ content: {
4216
+ type: "text",
4217
+ text: `No business rules found for domain "${domain}". Use the list-entries tool with collection "business-rules" to see available domains.`
4218
+ }
4219
+ }
4220
+ ]
4221
+ };
4222
+ }
4223
+ const rulesText = rules.map(
4224
+ (r) => `### ${r.entryId ?? ""}: ${r.name}
4225
+ Status: ${r.status} | Severity: ${r.data?.severity ?? "unknown"}
4226
+ Description: ${r.data?.description ?? ""}
4227
+ Data Impact: ${r.data?.dataImpact ?? ""}
4228
+ Platforms: ${(r.data?.platforms ?? []).join(", ")}
4229
+ ` + (r.data?.conflictWith ? `CONFLICT: ${r.data.conflictWith.rule} \u2014 ${r.data.conflictWith.nature}
4230
+ ` : "")
4231
+ ).join("\n---\n\n");
4232
+ return {
4233
+ messages: [
4234
+ {
4235
+ role: "user",
4236
+ content: {
4237
+ type: "text",
4238
+ text: `Review the current code or design against the following business rules for the "${domain}" domain.
4239
+
4240
+ For each rule, assess:
4241
+ 1. Is the current implementation compliant?
4242
+ 2. Are there potential violations or edge cases?
4243
+ 3. What specific changes would be needed for compliance?
4244
+
4245
+ Business Rules:
4246
+
4247
+ ${rulesText}
4248
+
4249
+ Provide a structured review with a compliance status for each rule (COMPLIANT / AT RISK / VIOLATION / NOT APPLICABLE).`
4250
+ }
4251
+ }
4252
+ ]
4253
+ };
4254
+ }
4255
+ );
4256
+ server.prompt(
4257
+ "name-check",
4258
+ "Check variable names, field names, or API names against the glossary for terminology alignment. Flags drift from canonical terms.",
4259
+ { names: z10.string().describe("Comma-separated list of names to check (e.g. 'vendor_id, compliance_level, formulator_type')") },
4260
+ async ({ names }) => {
4261
+ const terms = await mcpQuery("chain.listEntries", { collectionSlug: "glossary" });
4262
+ const glossaryContext = terms.map(
4263
+ (t) => `${t.name} (${t.entryId ?? ""}) [${t.status}]: ${t.data?.canonical ?? ""}` + (t.data?.confusedWith?.length > 0 ? ` \u2014 Often confused with: ${t.data.confusedWith.join(", ")}` : "") + (t.data?.codeMapping?.length > 0 ? `
4264
+ Code mappings: ${t.data.codeMapping.map((m) => `${m.platform}:${m.field}`).join(", ")}` : "")
4265
+ ).join("\n");
4266
+ return {
4267
+ messages: [
4268
+ {
4269
+ role: "user",
4270
+ content: {
4271
+ type: "text",
4272
+ text: `Check the following names against the glossary for terminology alignment:
4273
+
4274
+ Names to check: ${names}
4275
+
4276
+ Glossary (canonical terms):
4277
+ ${glossaryContext}
4278
+
4279
+ For each name:
4280
+ 1. Does it match a canonical term? If so, which one?
4281
+ 2. Is there terminology drift? (e.g. using "vendor" instead of "supplier", "compliance" instead of "conformance")
4282
+ 3. Suggest the canonical alternative if drift is detected.
4283
+ 4. Flag any names that don't have a corresponding glossary term (might need one).
4284
+
4285
+ Format as a table: Name | Status | Canonical Form | Action Needed`
4286
+ }
4287
+ }
4288
+ ]
4289
+ };
4290
+ }
4291
+ );
4292
+ server.prompt(
4293
+ "draft-decision-record",
4294
+ "Draft a structured decision record from a description of what was decided. Includes context from recent decisions and relevant rules.",
4295
+ { context: z10.string().describe("Description of the decision (e.g. 'We decided to use MRSL v3.1 as the conformance baseline because...')") },
4296
+ async ({ context }) => {
4297
+ const recentDecisions = await mcpQuery("chain.listEntries", { collectionSlug: "decisions" });
4298
+ const sorted = [...recentDecisions].sort((a, b) => (b.data?.date ?? "") > (a.data?.date ?? "") ? 1 : -1).slice(0, 5);
4299
+ const recentContext = sorted.length > 0 ? sorted.map((d) => `- [${d.status}] ${d.name} (${d.data?.date ?? "no date"})`).join("\n") : "No previous decisions recorded.";
4300
+ return {
4301
+ messages: [
4302
+ {
4303
+ role: "user",
4304
+ content: {
4305
+ type: "text",
4306
+ text: `Draft a structured decision record from the following context:
4307
+
4308
+ "${context}"
4309
+
4310
+ Recent decisions for reference:
4311
+ ${recentContext}
4312
+
4313
+ Structure the decision record with:
4314
+ 1. **Title**: Concise decision statement
4315
+ 2. **Decided by**: Who made or approved this decision
4316
+ 3. **Date**: When it was decided
4317
+ 4. **Status**: decided / proposed / revisited
4318
+ 5. **Rationale**: Why this decision was made, including trade-offs considered
4319
+ 6. **Alternatives considered**: What else was on the table
4320
+ 7. **Related rules or tensions**: Any business rules or tensions this connects to
4321
+
4322
+ After drafting, I can log it using the capture tool with collection "decisions".`
4323
+ }
4324
+ }
4325
+ ]
4326
+ };
4327
+ }
4328
+ );
4329
+ server.prompt(
4330
+ "draft-rule-from-context",
4331
+ "Draft a new business rule from an observation or discovery made while coding. Fetches existing rules for the domain to ensure consistency.",
4332
+ {
4333
+ observation: z10.string().describe("What you observed or discovered (e.g. 'Suppliers can have multiple org types in Gateway')"),
4334
+ domain: z10.string().describe("Which domain this rule belongs to (e.g. 'Governance & Decision-Making')")
4335
+ },
4336
+ async ({ observation, domain }) => {
4337
+ const allRules = await mcpQuery("chain.listEntries", { collectionSlug: "business-rules" });
4338
+ const existingRules = allRules.filter((r) => r.data?.domain === domain);
4339
+ const existingContext = existingRules.length > 0 ? existingRules.map((r) => `${r.entryId ?? ""}: ${r.name} [${r.status}] \u2014 ${r.data?.description ?? ""}`).join("\n") : "No existing rules for this domain.";
4340
+ const highestRuleNum = allRules.map((r) => parseInt((r.entryId ?? "").replace(/^[A-Z]+-/, ""), 10)).filter((n) => !isNaN(n)).sort((a, b) => b - a)[0] || 0;
4341
+ const nextRuleId = `SOS-${String(highestRuleNum + 1).padStart(3, "0")}`;
4342
+ return {
4343
+ messages: [
4344
+ {
4345
+ role: "user",
4346
+ content: {
4347
+ type: "text",
4348
+ text: `Draft a business rule based on this observation:
4349
+
4350
+ "${observation}"
4351
+
4352
+ Domain: ${domain}
4353
+ Suggested rule ID: ${nextRuleId}
4354
+
4355
+ Existing rules in this domain:
4356
+ ${existingContext}
4357
+
4358
+ Draft the rule with these fields:
4359
+ 1. **entryId**: ${nextRuleId}
4360
+ 2. **name**: Concise rule title
4361
+ 3. **data.description**: What the rule states
4362
+ 4. **data.rationale**: Why this rule matters
4363
+ 5. **data.dataImpact**: How this affects data models, APIs, or storage
4364
+ 6. **data.severity**: high / medium / low
4365
+ 7. **data.platforms**: Which platforms are affected
4366
+ 8. **data.relatedRules**: Any related existing rules
4367
+
4368
+ Make sure the rule is consistent with existing rules and doesn't contradict them. After drafting, I can create it using the capture tool with collection "business-rules".`
4369
+ }
4370
+ }
4371
+ ]
4372
+ };
4373
+ }
4374
+ );
4375
+ server.prompt(
4376
+ "run-workflow",
4377
+ "Launch a Chainwork workflow (retro, shape, IDM) in Facilitator Mode. Returns the full workflow definition, facilitation instructions, and round structure. The agent enters Facilitator Mode and guides the participant through each round.",
4378
+ {
4379
+ workflow: z10.string().describe(
4380
+ "Workflow ID to run. Available: " + listWorkflows().map((w) => `'${w.id}' (${w.name})`).join(", ")
4381
+ ),
4382
+ context: z10.string().optional().describe(
4383
+ "Optional context from the participant (e.g., 'retro on last sprint', 'shape the Chainwork API bet')"
4384
+ )
4385
+ },
4386
+ async ({ workflow: workflowId, context }) => {
4387
+ const wf = getWorkflow(workflowId);
4388
+ if (!wf) {
4389
+ const available = listWorkflows().map((w) => `- **${w.id}**: ${w.name} \u2014 ${w.shortDescription}`).join("\n");
4390
+ return {
4391
+ messages: [
4392
+ {
4393
+ role: "user",
4394
+ content: {
4395
+ type: "text",
4396
+ text: `Workflow "${workflowId}" not found.
4397
+
4398
+ Available workflows:
4399
+ ${available}
4400
+
4401
+ Use one of these IDs to run a workflow.`
4402
+ }
4403
+ }
4404
+ ]
4405
+ };
4406
+ }
4407
+ let kbContext = "";
4408
+ try {
4409
+ const recentDecisions = await mcpQuery("chain.listEntries", {
4410
+ collectionSlug: wf.kbOutputCollection
4411
+ });
4412
+ const sorted = [...recentDecisions].sort((a, b) => (b.data?.date ?? "") > (a.data?.date ?? "") ? 1 : -1).slice(0, 5);
4413
+ if (sorted.length > 0) {
4414
+ kbContext = `
4415
+ ## Recent ${wf.kbOutputCollection} entries (for context)
4416
+ ` + sorted.map((d) => `- ${d.entryId ?? ""}: ${d.name} [${d.status}]`).join("\n");
4417
+ }
4418
+ } catch {
4419
+ kbContext = "\n_Could not load chain context \u2014 proceed without it._";
4420
+ }
4421
+ const roundsPlan = wf.rounds.map(
4422
+ (r) => `### Round ${r.num}: ${r.label}
4423
+ **Type**: ${r.type} | **Duration**: ~${r.maxDurationHint ?? "5 min"}
4424
+ **Instruction**: ${r.instruction}
4425
+ **Facilitator guidance**: ${r.facilitatorGuidance}
4426
+ ` + (r.questions ? r.questions.map(
4427
+ (q) => `**Question**: ${q.prompt}
4428
+ ` + (q.options ? q.options.map((o) => ` - ${o.id}: ${o.label}`).join("\n") : " _(open response)_")
4429
+ ).join("\n") : "") + `
4430
+ **Output**: Capture to \`${r.outputSchema.field}\` (${r.outputSchema.format})`
4431
+ ).join("\n\n---\n\n");
4432
+ const contextLine = context ? `
4433
+ The participant provided this context: "${context}"
4434
+ Use it \u2014 don't make them repeat themselves.
4435
+ ` : "";
4436
+ return {
4437
+ messages: [
4438
+ {
4439
+ role: "user",
4440
+ content: {
4441
+ type: "text",
4442
+ text: `# ${wf.icon} ${wf.name} Workflow \u2014 Facilitator Mode
4443
+
4444
+ ${wf.shortDescription}
4445
+ ` + contextLine + `
4446
+ ---
4447
+
4448
+ ## Facilitator Instructions
4449
+
4450
+ ${wf.facilitatorPreamble}
4451
+
4452
+ ---
4453
+
4454
+ ## Rounds
4455
+
4456
+ ${roundsPlan}
4457
+
4458
+ ---
4459
+
4460
+ ## Error Recovery
4461
+
4462
+ ${wf.errorRecovery}
4463
+
4464
+ ---
4465
+
4466
+ ## Chain Output
4467
+
4468
+ When complete, use \`capture\` to create a \`${wf.kbOutputCollection}\` entry.
4469
+ Name template: ${wf.kbOutputTemplate.nameTemplate}
4470
+ Description field: ${wf.kbOutputTemplate.descriptionField}
4471
+ ` + kbContext + `
4472
+
4473
+ ---
4474
+
4475
+ **BEGIN THE WORKFLOW NOW.** Start with Round 01. Create a Plan first.`
4476
+ }
4477
+ }
4478
+ ]
4479
+ };
4480
+ }
4481
+ );
4482
+ }
4483
+
4484
+ // src/server.ts
4485
+ var SERVER_VERSION = "0.0.1-beta";
4486
+ var INSTRUCTIONS = [
4487
+ "Product Brain \u2014 the single source of truth for product knowledge.",
4488
+ "Terminology, standards, and core data all live here \u2014 no need to check external docs.",
4489
+ "",
4490
+ "Workflow:",
4491
+ " 1. Start: call `agent-start` to begin a tracked session.",
4492
+ " 2. Orient: call `orient` to load workspace context and unlock write tools.",
4493
+ " 3. Discover: use `search` to find entries, or `list-entries` to browse.",
4494
+ " 4. Drill in: use `get-entry` for full details \u2014 data, labels, relations, history.",
4495
+ " 5. Context: use `gather-context` with an entryId or a task description.",
4496
+ " 6. Capture: use `capture` to create entries \u2014 auto-links and scores in one call.",
4497
+ " 7. Commit: use `commit-entry` to promote drafts to SSOT \u2014 only when the user confirms.",
4498
+ " 8. Connect: use `suggest-links` then `relate-entries` to build the graph.",
4499
+ " 9. Close: call `agent-close` when done \u2014 records session activity.",
4500
+ "",
4501
+ "Write tools (capture, update-entry, relate-entries, commit-entry) require:",
4502
+ " - An active agent session (call agent-start)",
4503
+ " - Completed orientation (call orient)",
4504
+ " - A readwrite API key scope",
4505
+ "",
4506
+ "Commit-on-confirm: always capture as draft first and show the user what was captured.",
4507
+ "Only call `commit-entry` when the user explicitly confirms (e.g. 'commit', 'looks good', 'yes').",
4508
+ "This builds trust \u2014 the Chain (main) is SSOT; nothing goes there without user consent.",
4509
+ "",
4510
+ "Workspace setup: use `create-collection` and `update-collection` to shape the workspace",
4511
+ "structure with the user. Ask what they need to track; presets are starting points, not fixed.",
4512
+ "",
4513
+ "Personalization: if you have context about the user from memory (prior work, recent",
4514
+ "conversations, team context), use it to personalize recommendations. For example,",
4515
+ "'Based on your recent pitch reviews, the gap most likely to matter is X.'",
4516
+ "The orient/start output gives you the workspace state; your memory fills in the human context."
4517
+ ].join("\n");
4518
+ function createProductBrainServer() {
4519
+ const server = new McpServer2(
4520
+ { name: "Product Brain", version: SERVER_VERSION },
4521
+ { capabilities: { logging: {} }, instructions: INSTRUCTIONS }
4522
+ );
4523
+ const enabledModules = new Set(
4524
+ (process.env.PB_MODULES ?? "core,gitchain,arch").split(",").map((m) => m.trim().toLowerCase())
4525
+ );
4526
+ registerSessionTools(server);
4527
+ registerKnowledgeTools(server);
4528
+ registerLabelTools(server);
4529
+ registerHealthTools(server);
4530
+ registerVerifyTools(server);
4531
+ registerSmartCaptureTools(server);
4532
+ registerWorkflowTools(server);
4533
+ if (enabledModules.has("gitchain")) registerGitChainTools(server);
4534
+ if (enabledModules.has("gitchain")) registerMapTools(server);
4535
+ if (enabledModules.has("arch")) registerArchitectureTools(server);
4536
+ registerStartTools(server);
4537
+ registerResources(server);
4538
+ registerPrompts(server);
4539
+ return server;
4540
+ }
4541
+
4542
+ export {
4543
+ SERVER_VERSION,
4544
+ createProductBrainServer
4545
+ };
4546
+ //# sourceMappingURL=chunk-N47IMYAQ.js.map