@miragon/design-iq-mcp 5.0.0-rc.0

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,1890 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, posix, relative } from "node:path";
3
+ import { readdir } from "node:fs/promises";
4
+ import { parse } from "yaml";
5
+ import { XMLParser } from "fast-xml-parser";
6
+ import { fileURLToPath } from "node:url";
7
+ import { McpServer } from "@modelcontextprotocol/server";
8
+ import { z } from "zod";
9
+ //#region ../notations/bpmn-kinds.ts
10
+ const BPMN_ELEMENT_KIND = /* @__PURE__ */ new Map([
11
+ ["task", "activity"],
12
+ ["userTask", "activity"],
13
+ ["serviceTask", "activity"],
14
+ ["scriptTask", "activity"],
15
+ ["businessRuleTask", "activity"],
16
+ ["manualTask", "activity"],
17
+ ["sendTask", "activity"],
18
+ ["receiveTask", "activity"],
19
+ ["callActivity", "activity"],
20
+ ["subProcess", "activity"],
21
+ ["adHocSubProcess", "activity"],
22
+ ["transaction", "activity"],
23
+ ["startEvent", "event"],
24
+ ["endEvent", "event"],
25
+ ["intermediateThrowEvent", "event"],
26
+ ["intermediateCatchEvent", "event"],
27
+ ["boundaryEvent", "event"],
28
+ ["exclusiveGateway", "gateway"],
29
+ ["parallelGateway", "gateway"],
30
+ ["inclusiveGateway", "gateway"],
31
+ ["eventBasedGateway", "gateway"],
32
+ ["complexGateway", "gateway"]
33
+ ]);
34
+ /** the kind of a flow-node tag; undefined = not a flow node (artifact, lane, …) */
35
+ const kindOf = (tag) => BPMN_ELEMENT_KIND.get(tag);
36
+ /** every flow-node tag — everything else (textAnnotation, dataObject, …) is a
37
+ * legal artifact but NOT a flow node; treating it as one produces false
38
+ * "unreachable"/"dead end" errors on perfectly valid models */
39
+ const BPMN_FLOW_NODE_TAGS = new Set(BPMN_ELEMENT_KIND.keys());
40
+ /** container tags whose children form their own flow graph */
41
+ const BPMN_SUB_CONTAINER_TAGS = /* @__PURE__ */ new Set([
42
+ "subProcess",
43
+ "adHocSubProcess",
44
+ "transaction"
45
+ ]);
46
+ /** non-flow-node artifacts that still need a BPMNDI shape/edge to render.
47
+ * Exactly these five: plain `dataObject` is deliberately ABSENT — bpmn-js
48
+ * draws the dataObjectReference, not the dataObject; requiring DI for the
49
+ * latter would false-error every model with one. */
50
+ const BPMN_DI_ARTIFACT_TAGS = /* @__PURE__ */ new Set([
51
+ "textAnnotation",
52
+ "association",
53
+ "dataObjectReference",
54
+ "dataStoreReference",
55
+ "group"
56
+ ]);
57
+ //#endregion
58
+ //#region ../notations/index.ts
59
+ const NOTATIONS = [
60
+ {
61
+ id: "bpmn",
62
+ label: "BPMN 2.0",
63
+ noun: {
64
+ singular: "process",
65
+ plural: "processes"
66
+ },
67
+ extensions: [".bpmn"],
68
+ mediaKind: "xml",
69
+ docShape: "text",
70
+ monacoLanguage: "xml",
71
+ graphHints: {
72
+ flowEdgeKinds: ["sequenceFlow"],
73
+ entryNodeTypes: ["startEvent"]
74
+ }
75
+ },
76
+ {
77
+ id: "dmn",
78
+ label: "DMN",
79
+ noun: {
80
+ singular: "decision",
81
+ plural: "decisions"
82
+ },
83
+ extensions: [".dmn"],
84
+ mediaKind: "xml",
85
+ docShape: "text",
86
+ monacoLanguage: "xml",
87
+ graphHints: {
88
+ flowEdgeKinds: ["informationRequirement"],
89
+ entryNodeTypes: []
90
+ }
91
+ },
92
+ {
93
+ id: "wardley",
94
+ label: "Wardley Map",
95
+ noun: {
96
+ singular: "wardley map",
97
+ plural: "wardley maps"
98
+ },
99
+ extensions: [".owm", ".wmap"],
100
+ mediaKind: "dsl",
101
+ docShape: "text",
102
+ monacoLanguage: "plaintext",
103
+ graphHints: {
104
+ flowEdgeKinds: ["dependency"],
105
+ entryNodeTypes: []
106
+ }
107
+ },
108
+ {
109
+ id: "team-topology",
110
+ label: "Team Topology",
111
+ noun: {
112
+ singular: "team topology",
113
+ plural: "team topologies"
114
+ },
115
+ extensions: [".tt", ".ttm.json"],
116
+ mediaKind: "json",
117
+ docShape: "text",
118
+ monacoLanguage: "json"
119
+ },
120
+ {
121
+ id: "event-storming",
122
+ label: "Event Storming",
123
+ noun: {
124
+ singular: "event storming board",
125
+ plural: "event storming boards"
126
+ },
127
+ extensions: [".storm"],
128
+ mediaKind: "dsl",
129
+ docShape: "text",
130
+ monacoLanguage: "plaintext",
131
+ graphHints: {
132
+ flowEdgeKinds: ["arrow"],
133
+ entryNodeTypes: []
134
+ }
135
+ },
136
+ {
137
+ id: "context-map",
138
+ label: "Context Map",
139
+ noun: {
140
+ singular: "context map",
141
+ plural: "context maps"
142
+ },
143
+ extensions: [".cm.json"],
144
+ mediaKind: "json",
145
+ docShape: "text",
146
+ monacoLanguage: "json",
147
+ graphHints: {
148
+ flowEdgeKinds: [
149
+ "partnership",
150
+ "shared-kernel",
151
+ "customer-supplier",
152
+ "upstream-downstream",
153
+ "separate-ways"
154
+ ],
155
+ entryNodeTypes: []
156
+ }
157
+ },
158
+ {
159
+ id: "value-chain",
160
+ label: "Value Chain",
161
+ noun: {
162
+ singular: "value chain",
163
+ plural: "value chains"
164
+ },
165
+ extensions: [".vc.json"],
166
+ mediaKind: "json",
167
+ docShape: "text",
168
+ monacoLanguage: "json"
169
+ },
170
+ {
171
+ id: "markdown",
172
+ label: "Markdown",
173
+ noun: {
174
+ singular: "document",
175
+ plural: "documents"
176
+ },
177
+ extensions: [".md"],
178
+ mediaKind: "markdown",
179
+ docShape: "text",
180
+ monacoLanguage: "markdown"
181
+ }
182
+ ];
183
+ function byId(id) {
184
+ return NOTATIONS.find((n) => n.id === id);
185
+ }
186
+ /** longest-suffix winner across every registered extension, so ".vc.json"
187
+ * beats a hypothetical ".json" — shared by byExtension and modelStem */
188
+ function longestMatch(path) {
189
+ let best;
190
+ for (const n of NOTATIONS) for (const ext of n.extensions) if (path.endsWith(ext) && ext.length > (best?.ext.length ?? 0)) best = {
191
+ notation: n,
192
+ ext
193
+ };
194
+ return best;
195
+ }
196
+ function byExtension(path) {
197
+ return longestMatch(path)?.notation;
198
+ }
199
+ /**
200
+ * The file stem that IS a model's id (the content contract: a process is its
201
+ * .bpmn file, a decision its .dmn file). Strips the FULL registered extension,
202
+ * so compound suffixes resolve correctly ("a.vc.json" → "a", never "a.vc");
203
+ * unknown extensions fall back to stripping the final dot-suffix.
204
+ */
205
+ function modelStem(path) {
206
+ const base = path.split("/").pop() ?? path;
207
+ const ext = longestMatch(base)?.ext;
208
+ return ext ? base.slice(0, -ext.length) : base.replace(/\.[^.]+$/, "");
209
+ }
210
+ [...NOTATIONS.flatMap((n) => n.extensions)];
211
+ //#endregion
212
+ //#region ../notations/extract.ts
213
+ /**
214
+ * extract(raw) => ModelGraph — the analysis seam of the notation registry.
215
+ *
216
+ * Every notation knows how to turn its raw file content into ONE generic
217
+ * graph shape. Analyses (MCP tools, future dashboards) are written against
218
+ * ModelGraph and automatically cover every notation that implements extract —
219
+ * instead of one parser per tool per notation.
220
+ *
221
+ * Node-safe: fast-xml-parser + JSON + regex only, no DOM, no editor libraries.
222
+ */
223
+ const xml = new XMLParser({
224
+ ignoreAttributes: false,
225
+ attributeNamePrefix: "@_",
226
+ removeNSPrefix: true
227
+ });
228
+ /** the ONE fast-xml-parser configuration (the validator used to carry a
229
+ * byte-identical copy — that is how the two BPMN walks drifted) */
230
+ const parseXml = (raw) => xml.parse(raw);
231
+ const asArray = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
232
+ /**
233
+ * The decision a businessRuleTask delegates to. BPMN 2.0 has no standard
234
+ * attribute for it, so every engine invented its own — all of them are read
235
+ * here (namespace prefixes are already stripped by the parser):
236
+ *
237
+ * decisionRef="x" Camunda 7
238
+ * <extensionElements><calledDecision decisionId="x"/> Camunda 8 / Zeebe
239
+ * calledDecision="x" / calledElement="x" hand-written models
240
+ *
241
+ * The platform contract is the same as for callActivity: the value names the
242
+ * DECISION FILE STEM, so the link resolves against the repo's .dmn files.
243
+ *
244
+ * Exported as THE spelling list: the validator's link-integrity check must
245
+ * read exactly what the platform follows, or a spelling becomes a link that
246
+ * get_process reports and no check ever verifies (that drift shipped once).
247
+ */
248
+ function decisionRefOf(task) {
249
+ const called = task.extensionElements?.calledDecision;
250
+ const fromExtension = asArray(called)[0]?.["@_decisionId"];
251
+ const ref = task["@_decisionRef"] ?? fromExtension ?? task["@_calledDecision"] ?? task["@_calledElement"] ?? void 0;
252
+ const text = ref === void 0 || ref === null ? "" : String(ref).trim();
253
+ return text === "" ? void 0 : text;
254
+ }
255
+ /** parse-tree in (parseXml(raw).definitions), the shared model out */
256
+ function readBpmn(defs) {
257
+ const model = {
258
+ containers: [],
259
+ nodes: [],
260
+ flows: [],
261
+ diRequired: /* @__PURE__ */ new Set(),
262
+ lanes: [],
263
+ collaborations: []
264
+ };
265
+ const walk = (el, label, id, isSubProcess) => {
266
+ const container = {
267
+ label,
268
+ id,
269
+ isSubProcess,
270
+ triggeredByEvent: String(el["@_triggeredByEvent"]) === "true",
271
+ nodes: /* @__PURE__ */ new Map(),
272
+ flows: []
273
+ };
274
+ model.containers.push(container);
275
+ for (const [tag, value] of Object.entries(el)) {
276
+ if (tag.startsWith("@_")) continue;
277
+ if (tag === "sequenceFlow") {
278
+ for (const f of asArray(value)) {
279
+ const flow = {
280
+ id: f["@_id"] ?? "",
281
+ from: f["@_sourceRef"] ?? "",
282
+ to: f["@_targetRef"] ?? "",
283
+ name: f["@_name"]
284
+ };
285
+ container.flows.push(flow);
286
+ model.flows.push(flow);
287
+ if (f["@_id"]) model.diRequired.add(f["@_id"]);
288
+ }
289
+ continue;
290
+ }
291
+ if (BPMN_FLOW_NODE_TAGS.has(tag)) {
292
+ for (const node of asArray(value)) {
293
+ const rec = node;
294
+ const nodeId = rec["@_id"];
295
+ if (!nodeId) continue;
296
+ const decisionRef = tag === "businessRuleTask" ? decisionRefOf(node) : void 0;
297
+ const entry = {
298
+ id: nodeId,
299
+ tag,
300
+ name: rec["@_name"],
301
+ ...rec["@_attachedToRef"] !== void 0 ? { attachedTo: rec["@_attachedToRef"] } : {},
302
+ ...rec["@_calledElement"] !== void 0 ? { calledElement: rec["@_calledElement"] } : {},
303
+ ...decisionRef ? { decisionRef } : {},
304
+ container
305
+ };
306
+ model.nodes.push(entry);
307
+ container.nodes.set(nodeId, entry);
308
+ model.diRequired.add(nodeId);
309
+ if (BPMN_SUB_CONTAINER_TAGS.has(tag)) walk(node, `${tag} ${nodeId}`, nodeId, true);
310
+ }
311
+ continue;
312
+ }
313
+ if (BPMN_DI_ARTIFACT_TAGS.has(tag)) {
314
+ for (const node of asArray(value)) if (node["@_id"]) model.diRequired.add(node["@_id"]);
315
+ }
316
+ }
317
+ };
318
+ let processIndex = 0;
319
+ for (const proc of asArray(defs.process)) {
320
+ const rec = proc;
321
+ const pid = rec["@_id"];
322
+ walk(proc, `process ${pid ?? "(anonymous)"}`, pid, false);
323
+ for (const lane of asArray(rec.laneSet?.lane)) model.lanes.push({
324
+ id: lane["@_id"],
325
+ name: lane["@_name"],
326
+ nodeIds: asArray(lane.flowNodeRef).map(String),
327
+ processIndex
328
+ });
329
+ processIndex++;
330
+ }
331
+ for (const collab of asArray(defs.collaboration)) {
332
+ const rec = collab;
333
+ model.collaborations.push({
334
+ pools: asArray(rec.participant).map((pl) => ({
335
+ id: pl["@_id"],
336
+ name: pl["@_name"],
337
+ processRef: pl["@_processRef"]
338
+ })),
339
+ messageFlows: asArray(rec.messageFlow).map((mf) => ({
340
+ id: mf["@_id"],
341
+ from: mf["@_sourceRef"],
342
+ to: mf["@_targetRef"],
343
+ name: mf["@_name"]
344
+ }))
345
+ });
346
+ }
347
+ return model;
348
+ }
349
+ function extractBpmn(raw) {
350
+ const model = readBpmn(parseXml(raw).definitions ?? {});
351
+ const nodes = model.nodes.map((n) => ({
352
+ id: n.id,
353
+ type: n.tag,
354
+ name: n.name,
355
+ extra: {
356
+ ...n.container.isSubProcess && n.container.id ? { parent: n.container.id } : {},
357
+ ...n.calledElement ? { calledElement: n.calledElement } : {},
358
+ ...n.attachedTo ? { attachedTo: n.attachedTo } : {},
359
+ ...n.decisionRef ? { decisionRef: n.decisionRef } : {}
360
+ }
361
+ }));
362
+ const edges = model.flows.map((f) => ({
363
+ id: f.id,
364
+ from: f.from,
365
+ to: f.to,
366
+ kind: "sequenceFlow",
367
+ name: f.name
368
+ }));
369
+ for (const collab of model.collaborations) for (const mf of collab.messageFlows) edges.push({
370
+ id: mf.id ?? "",
371
+ from: mf.from ?? "",
372
+ to: mf.to ?? "",
373
+ kind: "messageFlow",
374
+ name: mf.name
375
+ });
376
+ return {
377
+ notation: "bpmn",
378
+ nodes,
379
+ edges,
380
+ meta: {
381
+ lanes: model.lanes.map((l) => ({
382
+ id: l.id,
383
+ name: l.name,
384
+ nodeIds: l.nodeIds
385
+ })),
386
+ pools: model.collaborations.flatMap((c) => c.pools.map((pl) => ({
387
+ id: pl.id ?? "",
388
+ name: pl.name ?? "",
389
+ processRef: pl.processRef ?? ""
390
+ })))
391
+ }
392
+ };
393
+ }
394
+ /**
395
+ * A tag's text content as a STRING. fast-xml-parser coerces tag values
396
+ * ("42" → 42, "true" → true), but every DMN entry is FEEL SOURCE TEXT — a
397
+ * rule entry `1` must stay the literal "1", never the number. Empty and
398
+ * self-closing tags collapse to "" (an empty entry means "any" in DMN).
399
+ */
400
+ function textOf(value) {
401
+ if (value === void 0 || value === null) return "";
402
+ if (typeof value === "object") return textOf(value["#text"]);
403
+ return String(value);
404
+ }
405
+ /**
406
+ * The target of an informationRequirement — requiredInput/requiredDecision,
407
+ * in that lookup order. THE spelling pair (it was byte-identical in the
408
+ * validator, the decisionRefOf duplication class): `local` means same-file
409
+ * ("#id"); a namespace-qualified href ("other.dmn#id") is NOT local — the
410
+ * validator's dangling check skips it, and extractDmn currently passes it
411
+ * through verbatim (known inconsistency vs analyze_decision, tracked in #86).
412
+ */
413
+ function requirementHref(req) {
414
+ const href = req.requiredInput?.["@_href"] ?? req.requiredDecision?.["@_href"];
415
+ if (href === void 0) return void 0;
416
+ return {
417
+ href,
418
+ local: href.startsWith("#")
419
+ };
420
+ }
421
+ /**
422
+ * DMN: decisions (with their full decision-table logic), input data and the
423
+ * information requirements between them. The table detail rides in
424
+ * node.extra — deriveDecision (derive.ts) turns it into the decision view,
425
+ * and the simulator (@designiq/decisions) into its evaluation model, so this is
426
+ * the ONE DMN parser on the server side.
427
+ */
428
+ function extractDmn(raw) {
429
+ const defs = parseXml(raw).definitions ?? {};
430
+ const nodes = [];
431
+ const edges = [];
432
+ for (const input of asArray(defs.inputData)) nodes.push({
433
+ id: input["@_id"] ?? "",
434
+ type: "inputData",
435
+ name: input["@_name"] ?? "",
436
+ extra: {
437
+ variable: input.variable?.["@_name"] ?? input["@_name"] ?? "",
438
+ typeRef: input.variable?.["@_typeRef"] ?? "string"
439
+ }
440
+ });
441
+ for (const decision of asArray(defs.decision)) {
442
+ const table = decision.decisionTable;
443
+ const literal = decision.literalExpression;
444
+ nodes.push({
445
+ id: decision["@_id"],
446
+ type: "decision",
447
+ name: decision["@_name"],
448
+ extra: {
449
+ variable: decision.variable?.["@_name"] ?? decision["@_name"] ?? decision["@_id"],
450
+ typeRef: decision.variable?.["@_typeRef"] ?? "string",
451
+ ...table ? {
452
+ kind: "decisionTable",
453
+ hitPolicy: table["@_hitPolicy"] ?? "UNIQUE",
454
+ ...table["@_aggregation"] ? { aggregation: table["@_aggregation"] } : {},
455
+ inputs: asArray(table.input).map((i, n) => ({
456
+ id: i["@_id"] ?? `Input_${n + 1}`,
457
+ label: i["@_label"] ?? textOf(i.inputExpression?.text),
458
+ expression: textOf(i.inputExpression?.text),
459
+ typeRef: i.inputExpression?.["@_typeRef"] ?? "string",
460
+ inputValues: splitFeelList(textOf(i.inputValues?.text))
461
+ })),
462
+ outputs: asArray(table.output).map((o, n) => ({
463
+ id: o["@_id"] ?? `Output_${n + 1}`,
464
+ name: o["@_name"] ?? o["@_label"] ?? "",
465
+ label: o["@_label"] ?? o["@_name"] ?? "",
466
+ typeRef: o["@_typeRef"] ?? "string",
467
+ outputValues: splitFeelList(textOf(o.outputValues?.text))
468
+ })),
469
+ rules: asArray(table.rule).map((r, n) => ({
470
+ id: r["@_id"] ?? `Rule_${n + 1}`,
471
+ description: textOf(r.description) || void 0,
472
+ inputEntries: asArray(r.inputEntry).map((e) => textOf(e.text)),
473
+ outputEntries: asArray(r.outputEntry).map((e) => textOf(e.text))
474
+ }))
475
+ } : literal ? {
476
+ kind: "literalExpression",
477
+ expression: textOf(literal.text)
478
+ } : { kind: "none" }
479
+ }
480
+ });
481
+ for (const req of asArray(decision.informationRequirement)) {
482
+ const target = requirementHref(req);
483
+ if (target && target.href) edges.push({
484
+ id: req["@_id"] ?? `req-${edges.length}`,
485
+ from: target.href.replace(/^#/, ""),
486
+ to: decision["@_id"],
487
+ kind: "informationRequirement"
488
+ });
489
+ }
490
+ }
491
+ return {
492
+ notation: "dmn",
493
+ nodes,
494
+ edges,
495
+ meta: {
496
+ name: defs["@_name"] ?? null,
497
+ namespace: defs["@_namespace"] ?? null
498
+ }
499
+ };
500
+ }
501
+ /** `"a","b"` (a DMN inputValues/outputValues list) → the raw FEEL items */
502
+ function splitFeelList(raw) {
503
+ const trimmed = raw.trim();
504
+ if (trimmed === "") return [];
505
+ return trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
506
+ }
507
+ /** OWM DSL: `component Name [y, x]`, `Name -> Other`, `evolve Name x` */
508
+ function extractWardley(raw) {
509
+ const nodes = [];
510
+ const edges = [];
511
+ for (const m of raw.matchAll(/^component\s+([^[\n]+?)\s*\[\s*([\d.]+)\s*,\s*([\d.]+)\s*\]/gm)) {
512
+ const evolution = Number(m[3]);
513
+ nodes.push({
514
+ id: m[1] ?? "",
515
+ type: "component",
516
+ name: m[1],
517
+ extra: {
518
+ visibility: Number(m[2]),
519
+ evolution,
520
+ stage: evolution < .25 ? "genesis" : evolution < .5 ? "custom-built" : evolution < .75 ? "product" : "commodity"
521
+ }
522
+ });
523
+ }
524
+ const directive = /^(title|component|anchor|market|note|annotation|annotations|style|evolution|submap|url|pipeline)\b/;
525
+ let i = 0;
526
+ for (const m of raw.matchAll(/^([^\s>[\n][^>[\n]*?)\s*->\s*([^\n;]+)$/gm)) {
527
+ if (directive.test(m[0] ?? "")) continue;
528
+ edges.push({
529
+ id: `dep-${i++}`,
530
+ from: m[1]?.trim() ?? "",
531
+ to: m[2]?.trim() ?? "",
532
+ kind: "dependency"
533
+ });
534
+ }
535
+ return {
536
+ notation: "wardley",
537
+ nodes,
538
+ edges
539
+ };
540
+ }
541
+ function extractTeamTopology(raw) {
542
+ const data = JSON.parse(raw);
543
+ return {
544
+ notation: "team-topology",
545
+ nodes: (data.nodes ?? []).map((n) => ({
546
+ id: n.id,
547
+ type: n.type ?? "team",
548
+ name: n.label ?? n.name,
549
+ extra: n.description ? { description: n.description } : void 0
550
+ })),
551
+ edges: (data.edges ?? []).map((e, i) => ({
552
+ id: e.id ?? `edge-${i}`,
553
+ from: e.source ?? e.from,
554
+ to: e.target ?? e.to,
555
+ kind: e.interaction ?? e.type ?? "interaction"
556
+ }))
557
+ };
558
+ }
559
+ const STORM_ID_PREFIX = {
560
+ event: "event",
561
+ command: "cmd",
562
+ actor: "actor",
563
+ aggregate: "agg",
564
+ policy: "policy",
565
+ readmodel: "read",
566
+ external: "ext",
567
+ hotspot: "hot"
568
+ };
569
+ /** stickies that can carry a pinned actor/hotspot/note — `(on <Host>)` */
570
+ const STORM_HOST_KINDS = /* @__PURE__ */ new Set([
571
+ "event",
572
+ "command",
573
+ "aggregate",
574
+ "policy",
575
+ "readmodel",
576
+ "external"
577
+ ]);
578
+ const STORM_ATTACHABLE_KINDS = /* @__PURE__ */ new Set([
579
+ "actor",
580
+ "hotspot",
581
+ "note"
582
+ ]);
583
+ const STORM_COORDS = /\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]/;
584
+ const STORM_POINTS = /\[\s*(\[[^[\]]*\](?:\s*,\s*\[[^[\]]*\])+)\s*\]/;
585
+ const stormSlug = (label) => label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_/, "").replace(/_$/, "") || "x";
586
+ /** the `(on <Host>)` / `(id <id>)` / `(color <c>)` suffixes of a sticky line;
587
+ * `rest` is the text with EVERY parenthesised suffix removed (size/align are
588
+ * note layout — irrelevant to the graph) */
589
+ function stormSuffixes(text) {
590
+ let host;
591
+ let rest = text;
592
+ const on = /\(\s*on\s+/i.exec(text);
593
+ if (on) {
594
+ const start = on.index + on[0].length;
595
+ const close = text.lastIndexOf(")");
596
+ if (close >= start && text.slice(start, close).trim()) {
597
+ host = text.slice(start, close).trim();
598
+ rest = `${text.slice(0, on.index)} ${text.slice(close + 1)}`;
599
+ }
600
+ }
601
+ const id = /\(\s*id\s+([A-Za-z0-9_-]+)\s*\)/i.exec(rest)?.[1];
602
+ const color = /\(\s*color\s+(#[0-9a-fA-F]{3,8}|[a-zA-Z][\w-]*)\s*\)/i.exec(rest)?.[1];
603
+ return {
604
+ host,
605
+ id,
606
+ color,
607
+ rest: rest.replace(/\([^)]*\)/g, " ")
608
+ };
609
+ }
610
+ function extractEventStorming(raw) {
611
+ const nodes = [];
612
+ const edges = [];
613
+ let title = null;
614
+ let level = null;
615
+ const used = /* @__PURE__ */ new Set();
616
+ const nameToId = /* @__PURE__ */ new Map();
617
+ const claim = (id) => {
618
+ if (!id || used.has(id)) return void 0;
619
+ used.add(id);
620
+ return id;
621
+ };
622
+ const alloc = (prefix, label) => {
623
+ const base = `${prefix}_${stormSlug(label)}`;
624
+ let id = base;
625
+ for (let i = 2; used.has(id); i++) id = `${base}_${i}`;
626
+ used.add(id);
627
+ return id;
628
+ };
629
+ const decode = (name) => name.replace(/\\n/g, "\n");
630
+ const arrows = [];
631
+ const pins = [];
632
+ for (const source of raw.replace(/\/\*[\s\S]*?\*\//g, " ").split(/\r?\n/)) {
633
+ const line = source.replace(/(?<!:)\/\/.*$/, "").trim();
634
+ if (!line) continue;
635
+ const kw = (/^[A-Za-z][\w-]*/.exec(line)?.[0] ?? "").toLowerCase();
636
+ const after = line.slice(kw.length).trim();
637
+ const semi = line.indexOf(";");
638
+ const core = (semi >= 0 ? line.slice(0, semi) : line).trim();
639
+ const arrowAt = core.indexOf("->");
640
+ if (arrowAt > 0 && !STORM_COORDS.test(core)) {
641
+ const from = core.slice(0, arrowAt).trim();
642
+ const to = core.slice(arrowAt + 2).trim();
643
+ if (from && to && (kw !== "title" || nameToId.has(decode(from)))) {
644
+ const label = semi >= 0 ? line.slice(semi + 1).trim() : "";
645
+ arrows.push({
646
+ from,
647
+ to,
648
+ ...label ? { label } : {}
649
+ });
650
+ continue;
651
+ }
652
+ }
653
+ if (kw === "title") title = after;
654
+ else if (kw === "level") {
655
+ const value = after.toLowerCase();
656
+ if ([
657
+ "big-picture",
658
+ "process",
659
+ "design"
660
+ ].includes(value)) level = value;
661
+ } else if (Object.hasOwn(STORM_ID_PREFIX, kw) || kw === "note") {
662
+ const at = STORM_COORDS.exec(after);
663
+ if (!at && after.includes("[")) continue;
664
+ const x = at ? Number(at[1]) : 0;
665
+ const y = at ? Number(at[2]) : 0;
666
+ if (Number.isNaN(x) || Number.isNaN(y)) continue;
667
+ const name = decode(at ? after.slice(0, at.index).trim() : stormSuffixes(after).rest.trim());
668
+ const { host, id: explicit, color } = stormSuffixes(at ? after.slice(at.index + at[0].length) : after);
669
+ if (kw !== "note" && !name) continue;
670
+ const id = kw === "note" ? alloc("note", name || "note") : claim(explicit) ?? alloc(STORM_ID_PREFIX[kw], name);
671
+ const node = {
672
+ id,
673
+ type: kw,
674
+ name,
675
+ extra: {
676
+ x,
677
+ y,
678
+ ...color ? { color } : {}
679
+ }
680
+ };
681
+ nodes.push(node);
682
+ if (kw !== "note" && !nameToId.has(name)) nameToId.set(name, id);
683
+ if (host && STORM_ATTACHABLE_KINDS.has(kw)) pins.push({
684
+ node,
685
+ host
686
+ });
687
+ } else if (kw === "line") {
688
+ const points = STORM_POINTS.exec(after)?.[1];
689
+ const tuples = points ? [...points.matchAll(/\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]/g)] : [];
690
+ if (tuples.length < 2) continue;
691
+ nodes.push({
692
+ id: alloc("draw", "line"),
693
+ type: "drawing",
694
+ extra: {
695
+ x: Number(tuples[0][1]),
696
+ y: Number(tuples[0][2]),
697
+ points: tuples.length
698
+ }
699
+ });
700
+ }
701
+ }
702
+ const resolve = (token) => {
703
+ const id = token.startsWith("#") ? token.slice(1) : nameToId.get(decode(token));
704
+ return id === void 0 ? void 0 : nodes.find((n) => n.id === id);
705
+ };
706
+ for (const { node, host } of pins) {
707
+ const target = resolve(host);
708
+ if (target && STORM_HOST_KINDS.has(target.type)) node.extra = {
709
+ ...node.extra,
710
+ attachedTo: target.id
711
+ };
712
+ }
713
+ let n = 0;
714
+ for (const arrow of arrows) {
715
+ const from = resolve(arrow.from);
716
+ const to = resolve(arrow.to);
717
+ if (!from || !to || !Object.hasOwn(STORM_ID_PREFIX, from.type) || !Object.hasOwn(STORM_ID_PREFIX, to.type)) continue;
718
+ let id = `arrow_${++n}`;
719
+ while (used.has(id)) id = `arrow_${++n}`;
720
+ edges.push({
721
+ id,
722
+ from: from.id,
723
+ to: to.id,
724
+ kind: "arrow",
725
+ name: arrow.label
726
+ });
727
+ }
728
+ return {
729
+ notation: "event-storming",
730
+ nodes,
731
+ edges,
732
+ meta: {
733
+ title,
734
+ level
735
+ }
736
+ };
737
+ }
738
+ const CM_SUBDOMAIN_TYPES$1 = /* @__PURE__ */ new Set([
739
+ "core",
740
+ "supporting",
741
+ "generic"
742
+ ]);
743
+ function extractContextMap(raw) {
744
+ const parsed = JSON.parse(raw);
745
+ const data = parsed !== null && typeof parsed === "object" ? parsed : {};
746
+ const contexts = Array.isArray(data.contexts) ? data.contexts : [];
747
+ const relationships = Array.isArray(data.relationships) ? data.relationships : [];
748
+ const text = (v) => typeof v === "string" && v !== "" ? v : void 0;
749
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
750
+ const roles = (v) => Array.isArray(v) ? v.filter((r) => typeof r === "string") : [];
751
+ const nodes = [];
752
+ for (const c of contexts) {
753
+ if (c === null || typeof c !== "object" || typeof c.id !== "string" || c.id === "") continue;
754
+ const subdomainType = text(c.subdomainType);
755
+ const team = text(c.team);
756
+ const description = text(c.description);
757
+ nodes.push({
758
+ id: c.id,
759
+ type: subdomainType && CM_SUBDOMAIN_TYPES$1.has(subdomainType) ? subdomainType : "context",
760
+ name: text(c.label),
761
+ extra: {
762
+ ...team ? { team } : {},
763
+ ...description ? { description } : {},
764
+ x: num(c.position?.x) ?? 0,
765
+ y: num(c.position?.y) ?? 0,
766
+ width: num(c.size?.width) ?? 0,
767
+ height: num(c.size?.height) ?? 0
768
+ }
769
+ });
770
+ }
771
+ const edges = [];
772
+ for (const [i, r] of relationships.entries()) {
773
+ if (r === null || typeof r !== "object") continue;
774
+ const upstreamRoles = roles(r.upstreamRoles);
775
+ const downstreamRoles = roles(r.downstreamRoles);
776
+ const implementationTechnology = text(r.implementationTechnology);
777
+ const description = text(r.description);
778
+ const extra = {
779
+ ...upstreamRoles.length > 0 ? { upstreamRoles } : {},
780
+ ...downstreamRoles.length > 0 ? { downstreamRoles } : {},
781
+ ...implementationTechnology ? { implementationTechnology } : {},
782
+ ...description ? { description } : {}
783
+ };
784
+ edges.push({
785
+ id: text(r.id) ?? `rel-${i}`,
786
+ from: text(r.from) ?? "",
787
+ to: text(r.to) ?? "",
788
+ kind: text(r.pattern) ?? "relationship",
789
+ name: text(r.label),
790
+ ...Object.keys(extra).length > 0 ? { extra } : {}
791
+ });
792
+ }
793
+ return {
794
+ notation: "context-map",
795
+ nodes,
796
+ edges,
797
+ meta: { title: text(data.title) ?? null }
798
+ };
799
+ }
800
+ function extractValueChain(raw) {
801
+ const data = JSON.parse(raw);
802
+ return {
803
+ notation: "value-chain",
804
+ nodes: (data.elements ?? []).map((el) => ({
805
+ id: el.id,
806
+ type: el.elementType ?? "element",
807
+ name: el.label ?? el.name
808
+ })),
809
+ edges: (data.connections ?? []).map((c, i) => ({
810
+ id: c.id ?? `conn-${i}`,
811
+ from: c.source,
812
+ to: c.target,
813
+ kind: c.connectionType ?? "connection"
814
+ }))
815
+ };
816
+ }
817
+ const EXTRACTORS = {
818
+ bpmn: extractBpmn,
819
+ dmn: extractDmn,
820
+ wardley: extractWardley,
821
+ "team-topology": extractTeamTopology,
822
+ "event-storming": extractEventStorming,
823
+ "context-map": extractContextMap,
824
+ "value-chain": extractValueChain
825
+ };
826
+ /** notation id or file path in, ModelGraph out; undefined = no extractor registered */
827
+ function extractModelGraph(notationOrPath, raw) {
828
+ const notation = byId(notationOrPath) ?? byExtension(notationOrPath);
829
+ const extractor = notation && EXTRACTORS[notation.id];
830
+ return extractor ? extractor(raw) : void 0;
831
+ }
832
+ //#endregion
833
+ //#region ../notations/refs.ts
834
+ const REF_EMITTERS = { bpmn: (graph) => {
835
+ const refs = [];
836
+ for (const n of graph.nodes) {
837
+ const calls = n.extra?.calledElement;
838
+ if (n.type === "callActivity" && typeof calls === "string" && calls) refs.push({
839
+ fromElement: n.id,
840
+ rel: "calls",
841
+ to: {
842
+ id: calls,
843
+ notation: "bpmn"
844
+ },
845
+ strength: "required"
846
+ });
847
+ }
848
+ for (const n of graph.nodes) {
849
+ const decides = n.extra?.decisionRef;
850
+ if (n.type === "businessRuleTask" && typeof decides === "string" && decides) refs.push({
851
+ fromElement: n.id,
852
+ rel: "decides",
853
+ to: {
854
+ id: decides,
855
+ notation: "dmn"
856
+ },
857
+ strength: "required"
858
+ });
859
+ }
860
+ return refs;
861
+ } };
862
+ /**
863
+ * THE generic refs dispatch: every outgoing cross-model reference of the
864
+ * graph; [] when the notation emits none. Registering a notation's emitter
865
+ * here is the whole integration — resolver, index, validation and the
866
+ * release PR section pick it up with zero consumer changes.
867
+ */
868
+ function refsOf(graph) {
869
+ return REF_EMITTERS[graph.notation]?.(graph) ?? [];
870
+ }
871
+ /**
872
+ * Every name the contract file is accepted under, in precedence order. The
873
+ * second one is the legacy name and stays readable permanently: customer
874
+ * repos, the starter template and every released image <= 4.2.0 carry it.
875
+ */
876
+ const CONTENT_CONFIG_FILES = ["designiq.yml", "bpmiq.yml"];
877
+ /**
878
+ * The NAME of the contract file at `root`: the first of CONTENT_CONFIG_FILES
879
+ * that exists. By existence ONLY — whether it parses is loadContentConfig's
880
+ * business. A present-but-broken first choice must NOT fall through to the
881
+ * next name: the file is plausibly mid-edit in a live session, and a fallback
882
+ * would silently flip the room-containment folder to a different one.
883
+ */
884
+ function resolveContentConfigFile(root) {
885
+ return CONTENT_CONFIG_FILES.find((name) => isContractFile(root, name));
886
+ }
887
+ /** a regular FILE of that name — a folder that merely carries a contract name
888
+ * (anyone may create one in a repo whose models live at the root) must not
889
+ * shadow the real contract file under the other name */
890
+ function isContractFile(root, name) {
891
+ try {
892
+ return statSync(join(root, name)).isFile();
893
+ } catch {
894
+ return false;
895
+ }
896
+ }
897
+ `${CONTENT_CONFIG_FILES[1]}`;
898
+ /**
899
+ * The folder ONE contract file names — `models:`, the legacy `processes:` key
900
+ * stays a full alias (existing repos remain valid unchanged). `undefined` for
901
+ * an unreadable/unparseable/ill-typed file.
902
+ */
903
+ function readContentFolder(root, name) {
904
+ let parsed;
905
+ try {
906
+ parsed = parse(readFileSync(join(root, name), "utf8"));
907
+ } catch {
908
+ return;
909
+ }
910
+ const raw = parsed;
911
+ const folder = raw?.models ?? raw?.processes;
912
+ if (typeof folder !== "string" || folder.trim().length === 0) return void 0;
913
+ const normalized = posix.normalize(folder.trim()).replace(/\/+$/, "");
914
+ if (normalized === "" || normalized === ".") return ".";
915
+ if (normalized.startsWith("/") || normalized.split("/").includes("..")) return void 0;
916
+ return normalized;
917
+ }
918
+ /**
919
+ * Read + validate the root contract file (resolveContentConfigFile picks which
920
+ * name). `undefined` means "not a content repo" — an unreadable/unparseable/
921
+ * ill-typed config must degrade to that, never crash a listing (the file is
922
+ * plausibly mid-edit in a live session).
923
+ */
924
+ function loadContentConfig(root) {
925
+ const name = resolveContentConfigFile(root);
926
+ const folder = name === void 0 ? void 0 : readContentFolder(root, name);
927
+ return folder === void 0 ? void 0 : {
928
+ models: folder,
929
+ processes: folder
930
+ };
931
+ }
932
+ /**
933
+ * Every file with a registered notation extension under the configured folder
934
+ * (recursive, sorted by path). The id is the file stem (modelStem) so it stays
935
+ * a single URL segment; ids are unique PER NOTATION (order.bpmn and order.dmn
936
+ * are legal separate namespaces) — a second same-notation file with the same
937
+ * stem elsewhere in the tree is skipped + logged rather than shadowing the
938
+ * first.
939
+ */
940
+ async function discoverModels(root, cfg) {
941
+ const dir = join(root, cfg.models);
942
+ let entries;
943
+ try {
944
+ entries = await readdir(dir, {
945
+ withFileTypes: true,
946
+ recursive: true
947
+ });
948
+ } catch {
949
+ return [];
950
+ }
951
+ const paths = entries.filter((e) => e.isFile()).map((e) => join(cfg.models, relative(dir, join(e.parentPath, e.name)))).filter((p) => !p.split("/").some((s) => s.startsWith(".") || s === "node_modules")).sort();
952
+ const out = [];
953
+ const seen = /* @__PURE__ */ new Map();
954
+ for (const path of paths) {
955
+ const notation = byExtension(path);
956
+ if (!notation) continue;
957
+ const id = modelStem(path);
958
+ const key = `${notation.id}/${id}`;
959
+ const first = seen.get(key);
960
+ if (first) {
961
+ console.log(`content: duplicate ${notation.id} id '${id}' (${path}) — keeping ${first}`);
962
+ continue;
963
+ }
964
+ seen.set(key, path);
965
+ out.push({
966
+ id,
967
+ path,
968
+ notation: notation.id
969
+ });
970
+ }
971
+ return out;
972
+ }
973
+ /**
974
+ * Build the reference index of a checkout: discover every model, run its
975
+ * extract + refs capabilities, resolve each reference against the discovered
976
+ * artifacts. Degrades per file (an unreadable/broken model simply emits no
977
+ * refs) — an index build must never fail a listing or a release.
978
+ */
979
+ async function buildRepoIndex(root, cfg) {
980
+ const artifacts = await discoverModels(root, cfg);
981
+ const byKey = /* @__PURE__ */ new Map();
982
+ for (const a of artifacts) byKey.set(`${a.notation}/${a.id}`, a);
983
+ const byId = (id, notation) => notation ? byKey.get(`${notation}/${id}`) : artifacts.find((a) => a.id === id);
984
+ const refs = [];
985
+ for (const artifact of artifacts) {
986
+ let emitted;
987
+ try {
988
+ const raw = readFileSync(join(root, artifact.path), "utf8");
989
+ const graph = extractModelGraph(artifact.path, raw);
990
+ emitted = graph ? refsOf(graph) : [];
991
+ } catch {
992
+ continue;
993
+ }
994
+ for (const ref of emitted) refs.push({
995
+ from: {
996
+ path: artifact.path,
997
+ notation: artifact.notation,
998
+ ...ref.fromElement ? { element: ref.fromElement } : {}
999
+ },
1000
+ rel: ref.rel,
1001
+ to: ref.to,
1002
+ strength: ref.strength,
1003
+ resolved: byId(ref.to.id, ref.to.notation)
1004
+ });
1005
+ }
1006
+ return {
1007
+ artifacts,
1008
+ refs,
1009
+ byId,
1010
+ outgoing: (path) => refs.filter((r) => r.from.path === path),
1011
+ incoming: (path) => refs.filter((r) => r.resolved?.path === path)
1012
+ };
1013
+ }
1014
+ /** every .bpmn under the configured folder — a process IS its BPMN file */
1015
+ async function discoverProcesses(root, cfg) {
1016
+ return (await discoverModels(root, cfg)).filter((m) => m.notation === "bpmn").map(({ id, path }) => ({
1017
+ id,
1018
+ path
1019
+ }));
1020
+ }
1021
+ //#endregion
1022
+ //#region ../contracts/src/todo-anchor.ts
1023
+ const OPEN = "<!-- bpmiq:todo v1";
1024
+ const CLOSE = "-->";
1025
+ function parseAnchor(text) {
1026
+ const start = text.indexOf(OPEN);
1027
+ if (start === -1) return null;
1028
+ const end = text.indexOf(CLOSE, start);
1029
+ if (end === -1) return null;
1030
+ const anchor = {
1031
+ process: "",
1032
+ file: null,
1033
+ elements: [],
1034
+ processVersion: null
1035
+ };
1036
+ for (const raw of text.slice(start + 18, end).split("\n")) {
1037
+ const line = raw.trim();
1038
+ const sep = line.indexOf(": ");
1039
+ if (sep === -1) continue;
1040
+ const key = line.slice(0, sep);
1041
+ const value = line.slice(sep + 2).trim();
1042
+ if (key === "process") anchor.process = value;
1043
+ else if (key === "file") anchor.file = value;
1044
+ else if (key === "version") anchor.processVersion = value;
1045
+ else if (key === "element") {
1046
+ const pipe = value.indexOf(" | ");
1047
+ if (pipe === -1) anchor.elements.push({
1048
+ id: value.replace(/ \|$/, "").trim(),
1049
+ name: null
1050
+ });
1051
+ else anchor.elements.push({
1052
+ id: value.slice(0, pipe).trim(),
1053
+ name: value.slice(pipe + 3).trim() || null
1054
+ });
1055
+ }
1056
+ }
1057
+ return anchor.process ? anchor : null;
1058
+ }
1059
+ //#endregion
1060
+ //#region ../github-app/src/todos.ts
1061
+ /**
1062
+ * The GitHub-side todo vocabulary: labels and the issues-list row shape. This
1063
+ * is TRACKER vocabulary, not platform wire types — ports/issue-tracker.ts
1064
+ * deliberately assumes no numbers, labels or markdown (Jira has neither), so
1065
+ * this lives with the GitHub plumbing, not in @designiq/contracts. The body
1066
+ * MARKUP (anchor block, attribution line) stays adapter-owned per that port.
1067
+ *
1068
+ * Zero imports — the read-only MCP server can adopt this without pulling the
1069
+ * App/JWT half of the package into its published bundle.
1070
+ */
1071
+ /** the label every platform-managed todo carries */
1072
+ const TODO_LABEL = "todo";
1073
+ /** the per-process label that makes the tracker-side filter cheap */
1074
+ const processLabel = (processId) => `process:${processId}`;
1075
+ /** the `labels=` filter for listing: every todo, or one process's */
1076
+ const todoLabelQuery = (processId) => processId ? `${TODO_LABEL},${processLabel(processId)}` : TODO_LABEL;
1077
+ const isPullRequestRow = (row) => row.pull_request !== void 0;
1078
+ //#endregion
1079
+ //#region ../mcp-kit/index.ts
1080
+ /** a successful tool result: strings pass through, everything else is
1081
+ * pretty-printed JSON in the first text block */
1082
+ const ok = (value) => ({ content: [{
1083
+ type: "text",
1084
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
1085
+ }] });
1086
+ /** an errored tool result — `message` must be agent-actionable text */
1087
+ const fail = (message) => ({
1088
+ content: [{
1089
+ type: "text",
1090
+ text: message
1091
+ }],
1092
+ isError: true
1093
+ });
1094
+ /**
1095
+ * Wrap a tool handler: thrown errors become `fail(...)` results. The prefix is
1096
+ * a deliberate per-server policy, not an accident of the old copies: the Live
1097
+ * Host passes none (its AppErrors carry actionable, agent-readable messages —
1098
+ * validation findings, conflict guidance, authz denials), the read-only server
1099
+ * marks everything unexpected.
1100
+ */
1101
+ const safe$1 = (fn, opts = {}) => async (args) => {
1102
+ try {
1103
+ return await fn(args ?? {});
1104
+ } catch (err) {
1105
+ return fail(`${opts.prefix ?? ""}${err.message}`);
1106
+ }
1107
+ };
1108
+ /** tool annotations: repo-local read */
1109
+ const READ = {
1110
+ readOnlyHint: true,
1111
+ openWorldHint: false
1112
+ };
1113
+ //#endregion
1114
+ //#region ../notations/derive.ts
1115
+ /**
1116
+ * The derive capability: ModelGraph → human/agent-readable views.
1117
+ *
1118
+ * deriveView(graph) is the GENERIC dispatch — one common core (name, summary,
1119
+ * stats, rich payload in `detail`) for every notation with a registered
1120
+ * deriver; consumers that want "a view of whatever this model is" (listings,
1121
+ * get_view) call it and never gate on the notation.
1122
+ *
1123
+ * deriveProcess(graph) => DerivedProcess is the rich BPMN view — the
1124
+ * "process.yaml on-the-fly": the slim content contract has NO hand-written
1125
+ * process metadata, so this turns the generic ModelGraph (extract.ts) into
1126
+ * name, roles (BPMN lanes), steps/events/gateways, flow and sub-process calls.
1127
+ * deriveDecision(graph) is its DMN sibling (decision table + DRD wiring).
1128
+ * Consumers that need those FIELDS keep calling them directly.
1129
+ *
1130
+ * Pure + browser-safe: operates on the already-parsed ModelGraph, no fs, no
1131
+ * XML — this module is imported eagerly by the web SPA
1132
+ * (CI: notations-index-and-derive-stay-browser-safe).
1133
+ */
1134
+ /**
1135
+ * Derive the process view from a BPMN ModelGraph. Non-BPMN graphs yield an empty
1136
+ * process (only BPMN carries process semantics); callers gate on graph.notation.
1137
+ */
1138
+ function deriveProcess(graph) {
1139
+ const meta = graph.meta ?? {};
1140
+ const lanes = meta.lanes ?? [];
1141
+ const pools = (meta.pools ?? []).map((p) => ({
1142
+ id: p.id,
1143
+ name: p.name || null
1144
+ }));
1145
+ const roleOf = /* @__PURE__ */ new Map();
1146
+ for (const lane of lanes) for (const nodeId of lane.nodeIds) roleOf.set(nodeId, lane.name ?? null);
1147
+ const element = (n) => {
1148
+ const calls = n.extra?.calledElement ?? void 0;
1149
+ const decides = n.extra?.decisionRef ?? void 0;
1150
+ return {
1151
+ id: n.id,
1152
+ name: n.name ?? null,
1153
+ type: n.type,
1154
+ ...roleOf.has(n.id) ? { role: roleOf.get(n.id) ?? null } : {},
1155
+ ...calls ? { calls } : {},
1156
+ ...decides ? { decides } : {}
1157
+ };
1158
+ };
1159
+ const steps = graph.nodes.filter((n) => kindOf(n.type) === "activity").map(element);
1160
+ const events = graph.nodes.filter((n) => kindOf(n.type) === "event").map(element);
1161
+ const gateways = graph.nodes.filter((n) => kindOf(n.type) === "gateway").map(element);
1162
+ const flows = graph.edges.map((e) => ({
1163
+ id: e.id,
1164
+ from: e.from,
1165
+ to: e.to,
1166
+ name: e.name ?? null,
1167
+ kind: e.kind
1168
+ }));
1169
+ const calls = graph.nodes.filter((n) => n.type === "callActivity" && n.extra?.calledElement).map((n) => ({
1170
+ id: n.id,
1171
+ name: n.name ?? null,
1172
+ calledElement: String(n.extra?.calledElement)
1173
+ }));
1174
+ const decisions = graph.nodes.filter((n) => n.extra?.decisionRef).map((n) => ({
1175
+ id: n.id,
1176
+ name: n.name ?? null,
1177
+ decisionRef: String(n.extra?.decisionRef)
1178
+ }));
1179
+ const roles = lanes.map((l) => ({
1180
+ id: l.id,
1181
+ name: l.name ?? null,
1182
+ stepIds: l.nodeIds
1183
+ }));
1184
+ return {
1185
+ name: pools.length === 1 ? pools[0]?.name || null : null,
1186
+ pools,
1187
+ roles,
1188
+ steps,
1189
+ events,
1190
+ gateways,
1191
+ flows,
1192
+ calls,
1193
+ decisions,
1194
+ stats: {
1195
+ steps: steps.length,
1196
+ events: events.length,
1197
+ gateways: gateways.length,
1198
+ flows: flows.length,
1199
+ roles: roles.length
1200
+ }
1201
+ };
1202
+ }
1203
+ const HIT_POLICIES = /* @__PURE__ */ new Set([
1204
+ "UNIQUE",
1205
+ "FIRST",
1206
+ "ANY",
1207
+ "PRIORITY",
1208
+ "COLLECT",
1209
+ "RULE ORDER",
1210
+ "OUTPUT ORDER"
1211
+ ]);
1212
+ const AGGREGATIONS = /* @__PURE__ */ new Set([
1213
+ "SUM",
1214
+ "MIN",
1215
+ "MAX",
1216
+ "COUNT"
1217
+ ]);
1218
+ /**
1219
+ * Derive the decision view from a DMN ModelGraph. Non-DMN graphs yield an
1220
+ * empty decision (only DMN carries decision semantics); callers gate on
1221
+ * graph.notation, exactly like deriveProcess.
1222
+ */
1223
+ function deriveDecision(graph) {
1224
+ const meta = graph.meta ?? {};
1225
+ const inputData = graph.nodes.filter((n) => n.type === "inputData").map((n) => ({
1226
+ id: n.id,
1227
+ name: n.name || null,
1228
+ variable: String(n.extra?.variable ?? n.name ?? n.id),
1229
+ typeRef: String(n.extra?.typeRef ?? "string")
1230
+ }));
1231
+ const inputDataIds = new Set(inputData.map((i) => i.id));
1232
+ const decisions = graph.nodes.filter((n) => n.type === "decision").map((n) => {
1233
+ const extra = n.extra ?? {};
1234
+ const sources = graph.edges.filter((e) => e.kind === "informationRequirement" && e.to === n.id);
1235
+ const hitPolicy = String(extra.hitPolicy ?? "");
1236
+ const aggregation = String(extra.aggregation ?? "");
1237
+ const rules = extra.rules ?? [];
1238
+ return {
1239
+ id: n.id,
1240
+ name: n.name || null,
1241
+ variable: String(extra.variable ?? n.name ?? n.id),
1242
+ typeRef: String(extra.typeRef ?? "string"),
1243
+ kind: extra.kind ?? "none",
1244
+ ...HIT_POLICIES.has(hitPolicy) ? { hitPolicy } : {},
1245
+ ...AGGREGATIONS.has(aggregation) ? { aggregation } : {},
1246
+ inputs: (extra.inputs ?? []).map((i) => ({ ...i })),
1247
+ outputs: (extra.outputs ?? []).map((o) => ({ ...o })),
1248
+ rules: rules.map((r) => ({
1249
+ id: String(r.id ?? ""),
1250
+ when: r.inputEntries ?? [],
1251
+ then: r.outputEntries ?? [],
1252
+ ...r.description ? { description: String(r.description) } : {}
1253
+ })),
1254
+ ...extra.expression ? { expression: String(extra.expression) } : {},
1255
+ requires: {
1256
+ inputData: sources.filter((e) => inputDataIds.has(e.from)).map((e) => e.from),
1257
+ decisions: sources.filter((e) => !inputDataIds.has(e.from)).map((e) => e.from)
1258
+ }
1259
+ };
1260
+ });
1261
+ return {
1262
+ name: meta.name || null,
1263
+ decisions,
1264
+ inputData,
1265
+ stats: {
1266
+ decisions: decisions.length,
1267
+ inputData: inputData.length,
1268
+ rules: decisions.reduce((sum, d) => sum + d.rules.length, 0)
1269
+ }
1270
+ };
1271
+ }
1272
+ /** "3 steps, 1 gateway" — deterministic stats prose for the summary line */
1273
+ const counted = (stats) => Object.entries(stats).map(([k, v]) => `${v} ${v === 1 ? k.replace(/ies$/, "y").replace(/s$/, "") : k}`).join(", ");
1274
+ /** the sticky kinds of an event storming board — the nodes with a place on
1275
+ * the timeline (notes and drawings are annotations). The .storm vocabulary,
1276
+ * mirrored here so this eager module stays free of extract.ts values */
1277
+ const STORM_STICKY_KINDS = /* @__PURE__ */ new Set([
1278
+ "event",
1279
+ "command",
1280
+ "actor",
1281
+ "aggregate",
1282
+ "policy",
1283
+ "readmodel",
1284
+ "external",
1285
+ "hotspot"
1286
+ ]);
1287
+ /** the board read the way a facilitator reads it — stickies left to right
1288
+ * (x, then y, then id: the schema-model's own sortByTimeline rule) */
1289
+ function stormTimeline(graph) {
1290
+ const pos = (n) => [Number(n.extra?.x ?? 0), Number(n.extra?.y ?? 0)];
1291
+ return graph.nodes.filter((n) => STORM_STICKY_KINDS.has(n.type)).sort((a, b) => {
1292
+ const [ax, ay] = pos(a);
1293
+ const [bx, by] = pos(b);
1294
+ return ax - bx || ay - by || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1295
+ }).map((n) => ({
1296
+ id: n.id,
1297
+ type: n.type,
1298
+ name: n.name ?? null
1299
+ }));
1300
+ }
1301
+ /** the context-map vocabulary (@miragon/context-maps-schema-model
1302
+ * SUBDOMAIN_TYPES / RELATIONSHIP_PATTERNS), in the notation's own order —
1303
+ * mirrored here like the storm kinds so this eager module stays zero-dep */
1304
+ const CM_SUBDOMAIN_TYPES = [
1305
+ "core",
1306
+ "supporting",
1307
+ "generic"
1308
+ ];
1309
+ const CM_RELATIONSHIP_PATTERNS = [
1310
+ "partnership",
1311
+ "shared-kernel",
1312
+ "customer-supplier",
1313
+ "upstream-downstream",
1314
+ "separate-ways"
1315
+ ];
1316
+ const DERIVERS = {
1317
+ bpmn: (graph) => {
1318
+ const d = deriveProcess(graph);
1319
+ return {
1320
+ notation: "bpmn",
1321
+ name: d.name,
1322
+ summary: `Process with ${counted(d.stats)}`,
1323
+ stats: { ...d.stats },
1324
+ detail: d
1325
+ };
1326
+ },
1327
+ dmn: (graph) => {
1328
+ const d = deriveDecision(graph);
1329
+ return {
1330
+ notation: "dmn",
1331
+ name: d.name,
1332
+ summary: `Decision model with ${counted(d.stats)}`,
1333
+ stats: { ...d.stats },
1334
+ detail: d
1335
+ };
1336
+ },
1337
+ wardley: (graph) => {
1338
+ const stats = {
1339
+ components: graph.nodes.length,
1340
+ dependencies: graph.edges.length
1341
+ };
1342
+ return {
1343
+ notation: "wardley",
1344
+ name: null,
1345
+ summary: `Wardley map with ${counted(stats)}`,
1346
+ stats
1347
+ };
1348
+ },
1349
+ "team-topology": (graph) => {
1350
+ const stats = {
1351
+ teams: graph.nodes.length,
1352
+ interactions: graph.edges.length
1353
+ };
1354
+ return {
1355
+ notation: "team-topology",
1356
+ name: null,
1357
+ summary: `Team topology with ${counted(stats)}`,
1358
+ stats
1359
+ };
1360
+ },
1361
+ "event-storming": (graph) => {
1362
+ const count = (type) => graph.nodes.filter((n) => n.type === type).length;
1363
+ const stats = {
1364
+ events: count("event"),
1365
+ commands: count("command"),
1366
+ actors: count("actor"),
1367
+ aggregates: count("aggregate"),
1368
+ policies: count("policy"),
1369
+ readmodels: count("readmodel"),
1370
+ externals: count("external"),
1371
+ hotspots: count("hotspot"),
1372
+ notes: count("note"),
1373
+ drawings: count("drawing"),
1374
+ arrows: graph.edges.length
1375
+ };
1376
+ const present = Object.fromEntries(Object.entries(stats).filter(([, v]) => v > 0));
1377
+ const title = graph.meta?.title;
1378
+ return {
1379
+ notation: "event-storming",
1380
+ name: typeof title === "string" ? title : null,
1381
+ summary: `Event storming board with ${Object.keys(present).length > 0 ? counted(present) : "no elements"}`,
1382
+ stats,
1383
+ detail: {
1384
+ level: graph.meta?.level ?? null,
1385
+ timeline: stormTimeline(graph)
1386
+ }
1387
+ };
1388
+ },
1389
+ "context-map": (graph) => {
1390
+ const stats = {
1391
+ contexts: graph.nodes.length,
1392
+ relationships: graph.edges.length
1393
+ };
1394
+ for (const t of CM_SUBDOMAIN_TYPES) stats[t] = graph.nodes.filter((n) => n.type === t).length;
1395
+ for (const p of CM_RELATIONSHIP_PATTERNS) stats[p] = graph.edges.filter((e) => e.kind === p).length;
1396
+ const breakdown = (keys) => {
1397
+ const present = keys.filter((k) => (stats[k] ?? 0) > 0).map((k) => `${stats[k]} ${k}`);
1398
+ return present.length > 0 ? ` (${present.join(", ")})` : "";
1399
+ };
1400
+ const title = graph.meta?.title;
1401
+ return {
1402
+ notation: "context-map",
1403
+ name: typeof title === "string" ? title : null,
1404
+ summary: `Context map with ${counted({ contexts: stats.contexts })}${breakdown(CM_SUBDOMAIN_TYPES)}, ${counted({ relationships: stats.relationships })}${breakdown(CM_RELATIONSHIP_PATTERNS)}`,
1405
+ stats,
1406
+ detail: {
1407
+ contexts: graph.nodes.map((n) => ({
1408
+ id: n.id,
1409
+ name: n.name ?? null,
1410
+ subdomainType: CM_SUBDOMAIN_TYPES.includes(n.type) ? n.type : null,
1411
+ team: n.extra?.team ?? null,
1412
+ description: n.extra?.description ?? null
1413
+ })),
1414
+ relationships: graph.edges.map((e) => ({
1415
+ id: e.id,
1416
+ from: e.from,
1417
+ to: e.to,
1418
+ pattern: e.kind,
1419
+ upstreamRoles: e.extra?.upstreamRoles ?? [],
1420
+ downstreamRoles: e.extra?.downstreamRoles ?? [],
1421
+ label: e.name ?? null,
1422
+ implementationTechnology: e.extra?.implementationTechnology ?? null
1423
+ }))
1424
+ }
1425
+ };
1426
+ },
1427
+ "value-chain": (graph) => {
1428
+ const stats = {
1429
+ elements: graph.nodes.length,
1430
+ connections: graph.edges.length
1431
+ };
1432
+ return {
1433
+ notation: "value-chain",
1434
+ name: null,
1435
+ summary: `Value chain with ${counted(stats)}`,
1436
+ stats
1437
+ };
1438
+ }
1439
+ };
1440
+ /**
1441
+ * THE generic derive dispatch: the view of whatever notation `graph` carries;
1442
+ * `undefined` when no deriver is registered (the caller falls back to the
1443
+ * bare listing row). Registering a notation's deriver here is the whole
1444
+ * integration — no consumer changes.
1445
+ */
1446
+ function deriveView(graph) {
1447
+ return DERIVERS[graph.notation]?.(graph);
1448
+ }
1449
+ /** whether a notation has a registered deriver — lets tool copy and
1450
+ * capability listings stay registry-driven instead of hand-enumerated */
1451
+ function hasDeriver(notation) {
1452
+ return notation in DERIVERS;
1453
+ }
1454
+ //#endregion
1455
+ //#region tools.ts
1456
+ /**
1457
+ * designiq-mcp-server — tool definitions, shared by both transports:
1458
+ * server.ts → stdio (local: Claude Code picks it up via .mcp.json)
1459
+ * http.ts → Streamable HTTP (remote: fly.io, any MCP client via URL)
1460
+ *
1461
+ * Read-only by construction: only readFileSync + the content-repo discovery,
1462
+ * no write path. All tools carry readOnlyHint so clients may auto-approve them.
1463
+ * The one opt-in exception to "repo-local" is list_todos (registered ONLY when
1464
+ * DESIGNIQ_TODOS_REPO + DESIGNIQ_TODOS_TOKEN are set — also read under the
1465
+ * legacy BPM_TODOS_* names): a read-only GET against the content // legacy-name-ok
1466
+ * repo's issue tracker — the zero-auth default stays untouched.
1467
+ *
1468
+ * The content contract is minimal (@designiq/notations/content): a repo is a
1469
+ * content repo iff it has a root designiq.yml (legacy name: bpmiq.yml) naming // legacy-name-ok
1470
+ * its models folder; a process IS a .bpmn file there. There is NO hand-written
1471
+ * process.yaml — the process view (name, roles, steps, flow, sub-process calls)
1472
+ * is DERIVED from the BPMN on the fly (@designiq/notations/derive). A new
1473
+ * notation with an extractor is automatically analyzable here.
1474
+ *
1475
+ * Content root: pass --root <dir> (server.ts) or DESIGNIQ_CONTENT_ROOT (the
1476
+ * legacy BPM_CONTENT_ROOT is still read) — any content repo works, the bundled // legacy-name-ok
1477
+ * process-documentation is only the default.
1478
+ */
1479
+ /** The content root the environment names — undefined when it names none. Read
1480
+ * under two names: DESIGNIQ_CONTENT_ROOT wins, BPM_CONTENT_ROOT keeps working. // legacy-name-ok
1481
+ * An EMPTY new name must not shadow a set old one (compose passes "" for an
1482
+ * unset variable). An empty value with nothing behind it stays what it always
1483
+ * was — "" (the cwd), NOT the bundled example — under either name, so swapping
1484
+ * one name for the other never moves the root. */
1485
+ function contentRootFromEnv(env) {
1486
+ return env.DESIGNIQ_CONTENT_ROOT || (env.BPM_CONTENT_ROOT ?? env.DESIGNIQ_CONTENT_ROOT);
1487
+ }
1488
+ /** This file lives at packages/mcp/; the example content repo is process-documentation/. */
1489
+ const DEFAULT_ROOT = contentRootFromEnv(process.env) ?? join(dirname(fileURLToPath(import.meta.url)), "..", "..", "process-documentation");
1490
+ const readText = (path) => {
1491
+ try {
1492
+ return readFileSync(path, "utf8");
1493
+ } catch {
1494
+ return null;
1495
+ }
1496
+ };
1497
+ const safe = (fn) => safe$1(fn, { prefix: "Unexpected error: " });
1498
+ /** every tool here is read-only and repo-local */
1499
+ const READ_ONLY = READ;
1500
+ /** what "flow" means in this graph's notation — undefined = no flow semantics */
1501
+ function flowKindsOf(graph) {
1502
+ const kinds = byId(graph.notation)?.graphHints?.flowEdgeKinds;
1503
+ return kinds && kinds.length > 0 ? kinds : void 0;
1504
+ }
1505
+ /** node -> outgoing flow-edge targets, per the notation's graphHints */
1506
+ function flowTargets(graph, kinds) {
1507
+ const out = /* @__PURE__ */ new Map();
1508
+ for (const e of graph.edges) {
1509
+ if (!kinds.includes(e.kind)) continue;
1510
+ (out.get(e.from) ?? out.set(e.from, []).get(e.from)).push(e.to);
1511
+ }
1512
+ return out;
1513
+ }
1514
+ /** all simple start→end paths through the notation's flow edges, cycle-cut, capped */
1515
+ function enumeratePaths(graph, kinds, max) {
1516
+ const out = flowTargets(graph, kinds);
1517
+ const label = new Map(graph.nodes.map((n) => [n.id, n.name ? `${n.name} (${n.type})` : `${n.id} (${n.type})`]));
1518
+ const entryTypes = byId(graph.notation)?.graphHints?.entryNodeTypes ?? [];
1519
+ const hasIncoming = new Set([...out.values()].flat());
1520
+ const starts = entryTypes.length > 0 ? graph.nodes.filter((n) => entryTypes.includes(n.type) && !n.extra?.parent) : graph.nodes.filter((n) => !hasIncoming.has(n.id) && (out.get(n.id)?.length ?? 0) > 0);
1521
+ const paths = [];
1522
+ let truncated = false;
1523
+ const walk = (node, seen, path) => {
1524
+ if (paths.length >= max) {
1525
+ truncated = true;
1526
+ return;
1527
+ }
1528
+ const next = out.get(node) ?? [];
1529
+ if (next.length === 0) {
1530
+ paths.push(path);
1531
+ return;
1532
+ }
1533
+ for (const target of next) {
1534
+ if (seen.has(target)) continue;
1535
+ walk(target, new Set(seen).add(target), [...path, label.get(target) ?? target]);
1536
+ }
1537
+ };
1538
+ for (const s of starts) walk(s.id, /* @__PURE__ */ new Set([s.id]), [label.get(s.id) ?? s.id]);
1539
+ return {
1540
+ paths,
1541
+ truncated
1542
+ };
1543
+ }
1544
+ /** cycles over the notation's flow edges (DFS back-edge detection) */
1545
+ function findCycles(graph, kinds) {
1546
+ const out = flowTargets(graph, kinds);
1547
+ const label = new Map(graph.nodes.map((n) => [n.id, n.name ?? n.id]));
1548
+ const cycles = [];
1549
+ const seenCycles = /* @__PURE__ */ new Set();
1550
+ const visit = (node, stack) => {
1551
+ const at = stack.indexOf(node);
1552
+ if (at >= 0) {
1553
+ const cycle = stack.slice(at);
1554
+ const key = [...cycle].sort().join("|");
1555
+ if (!seenCycles.has(key)) {
1556
+ seenCycles.add(key);
1557
+ cycles.push([...cycle, node].map((id) => label.get(id) ?? id));
1558
+ }
1559
+ return;
1560
+ }
1561
+ for (const next of out.get(node) ?? []) visit(next, [...stack, node]);
1562
+ };
1563
+ for (const n of graph.nodes) visit(n.id, []);
1564
+ return cycles;
1565
+ }
1566
+ /** The list_todos gate, used by BOTH entry points (server.ts, http.ts — the
1567
+ * composition roots read env, this module doesn't): undefined unless BOTH a
1568
+ * tracker repo (DESIGNIQ_TODOS_REPO) and a token (DESIGNIQ_TODOS_TOKEN) are
1569
+ * set — the server stays zero-auth by default, the tool simply does not exist
1570
+ * without the opt-in.
1571
+ *
1572
+ * Each of the two is also read under its legacy BPM_TODOS_* name (the new name // legacy-name-ok
1573
+ * wins) and resolved on its own: a half-migrated environment (repo under one
1574
+ * name, token under the other) still opts in rather than silently losing the
1575
+ * tool. Empty counts as unset (compose passes "" for an unset variable), so an
1576
+ * empty new name never shadows a set old one. */
1577
+ function todosConfigFromEnv(env) {
1578
+ const repo = env.DESIGNIQ_TODOS_REPO || env.BPM_TODOS_REPO;
1579
+ const token = env.DESIGNIQ_TODOS_TOKEN || env.BPM_TODOS_TOKEN;
1580
+ return repo && token ? {
1581
+ repo,
1582
+ token,
1583
+ apiUrl: env.GITHUB_API_URL
1584
+ } : void 0;
1585
+ }
1586
+ /** Build a fully configured, read-only MCP server over the repo at `root`. */
1587
+ function createMcpServer(root = DEFAULT_ROOT, todos, contributions = []) {
1588
+ const config = () => loadContentConfig(root);
1589
+ const processes = async () => {
1590
+ const cfg = config();
1591
+ return cfg ? discoverProcesses(root, cfg) : [];
1592
+ };
1593
+ const findProcess = async (id) => (await processes()).find((p) => p.id === id) ?? null;
1594
+ /** any-notation lookup; a stem shared across notations resolves bpmn-first
1595
+ * (back-compat: process ids keep meaning what they always meant) */
1596
+ const findModel = async (id, notation) => {
1597
+ const cfg = config();
1598
+ if (!cfg) return null;
1599
+ const matches = (await discoverModels(root, cfg)).filter((m) => m.id === id && (!notation || m.notation === notation));
1600
+ return matches.find((m) => m.notation === "bpmn") ?? matches[0] ?? null;
1601
+ };
1602
+ const unknownModel = async (id, notation) => {
1603
+ const cfg = config();
1604
+ const listed = (cfg ? await discoverModels(root, cfg) : []).filter((m) => !notation || m.notation === notation).map((m) => `${m.id} (${m.notation})`).join(", ") || "(none)";
1605
+ const what = notation ? byId(notation)?.noun.singular ?? notation : "model";
1606
+ return fail(`Unknown ${what} '${id}'. Available: ${listed}.`);
1607
+ };
1608
+ /** parse a discovered process's .bpmn into a ModelGraph, or a failure result */
1609
+ const graphOf = (proc) => {
1610
+ const raw = readText(join(root, proc.path));
1611
+ if (raw === null) return fail(`No such model file: ${proc.path}.`);
1612
+ const graph = extractModelGraph(proc.path, raw);
1613
+ if (!graph) return fail(`No extractor for '${proc.path}'.`);
1614
+ return graph;
1615
+ };
1616
+ const isGraph = (g) => !("content" in g);
1617
+ /** the registry nouns for user-facing copy ("wardley map" / "team topologies") */
1618
+ const nounOf = (notation) => byId(notation)?.noun.singular ?? notation;
1619
+ const pluralOf = (notation) => byId(notation)?.noun.plural ?? notation;
1620
+ const notAContentRepo = () => fail(`No usable designiq.yml (or legacy bpmiq.yml) at the content root — not a content repo. Expected a root designiq.yml naming the models folder.`);
1621
+ const unknownProcess = async (id) => fail(`Unknown process '${id}'. Available: ${(await processes()).map((p) => p.id).join(", ") || "(none)"}.`);
1622
+ const server = new McpServer({
1623
+ name: "designiq-mcp",
1624
+ version: "0.2.0"
1625
+ });
1626
+ server.registerTool("list_models", {
1627
+ description: "List EVERY model file of the repository, grouped by notation (bpmn, dmn, wardley, team-topology, …) — the registry-wide superset of list_processes. Each row: id (file stem), path, and — for notations with a registered deriver — the model's own name, summary and stats. Use to see which notations a repo contains before reaching for the notation-specific tools.",
1628
+ annotations: READ_ONLY
1629
+ }, safe(async () => {
1630
+ const cfg = config();
1631
+ if (!cfg) return notAContentRepo();
1632
+ const models = await discoverModels(root, cfg);
1633
+ if (models.length === 0) return ok("No model files found under the configured models folder.");
1634
+ const grouped = {};
1635
+ for (const m of models) {
1636
+ let view;
1637
+ try {
1638
+ const raw = readText(join(root, m.path));
1639
+ const graph = raw === null ? void 0 : extractModelGraph(m.path, raw);
1640
+ view = graph ? deriveView(graph) : void 0;
1641
+ } catch {
1642
+ view = void 0;
1643
+ }
1644
+ (grouped[m.notation] ??= []).push({
1645
+ id: m.id,
1646
+ path: m.path,
1647
+ ...view?.name ? { name: view.name } : {},
1648
+ ...view ? {
1649
+ summary: view.summary,
1650
+ stats: view.stats
1651
+ } : {}
1652
+ });
1653
+ }
1654
+ return ok({ models: grouped });
1655
+ }));
1656
+ server.registerTool("list_processes", {
1657
+ description: "List all modeled business processes — every .bpmn file under the repo's designiq.yml models folder. Each row: id (file name without extension), derived name, the file path, and a count of steps/events/gateways/roles. Use to get a portfolio overview or to find a process id before calling get_process, get_model, who_owns or enumerate_paths.",
1658
+ annotations: READ_ONLY
1659
+ }, safe(async () => {
1660
+ if (!config()) return notAContentRepo();
1661
+ const procs = await processes();
1662
+ if (procs.length === 0) return ok("No processes found — no .bpmn files under the configured processes folder.");
1663
+ return ok(procs.map((p) => {
1664
+ const graph = graphOf(p);
1665
+ if (!isGraph(graph)) return {
1666
+ id: p.id,
1667
+ path: p.path,
1668
+ error: "could not parse BPMN"
1669
+ };
1670
+ const d = deriveProcess(graph);
1671
+ return {
1672
+ id: p.id,
1673
+ name: d.name ?? p.id,
1674
+ path: p.path,
1675
+ ...d.stats
1676
+ };
1677
+ }));
1678
+ }));
1679
+ server.registerTool("get_process", {
1680
+ description: "Get one process in full: the process view DERIVED from its BPMN — name, roles (BPMN lanes = owning teams), steps (activities with their role), events, gateways, the sequence/message flow, and the sub-processes it calls (callActivity → calledElement). Use for walkthroughs and any deep question about a single process.",
1681
+ inputSchema: z.object({ id: z.string().describe("Process id = the .bpmn file name without extension, e.g. order-to-cash") }),
1682
+ annotations: READ_ONLY
1683
+ }, safe(async ({ id }) => {
1684
+ if (!config()) return notAContentRepo();
1685
+ const proc = await findProcess(id);
1686
+ if (!proc) return unknownProcess(id);
1687
+ const graph = graphOf(proc);
1688
+ if (!isGraph(graph)) return graph;
1689
+ return ok({
1690
+ id: proc.id,
1691
+ path: proc.path,
1692
+ ...deriveProcess(graph)
1693
+ });
1694
+ }));
1695
+ server.registerTool("get_model", {
1696
+ description: "Parse ANY model file into its generic graph: nodes (id, type, name), edges and meta — BPMN flows and lanes, DMN requirements, Wardley components/dependencies, … Use to see the raw structure, or to ground any 'how does X work' answer in the actual model.",
1697
+ inputSchema: z.object({
1698
+ id: z.string().describe("Model id = file stem (from list_models), e.g. order-to-cash"),
1699
+ notation: z.string().optional().describe("registry notation id — disambiguates a stem shared across notations")
1700
+ }),
1701
+ annotations: READ_ONLY
1702
+ }, safe(async ({ id, notation }) => {
1703
+ if (!config()) return notAContentRepo();
1704
+ const m = await findModel(id, notation);
1705
+ if (!m) return unknownModel(id, notation);
1706
+ const graph = graphOf(m);
1707
+ return isGraph(graph) ? ok({
1708
+ id: m.id,
1709
+ file: m.path,
1710
+ ...graph
1711
+ }) : graph;
1712
+ }));
1713
+ server.registerTool("get_view", {
1714
+ description: `The derived view of ANY model — its own name, a one-line summary, stats, and the rich notation payload in \`detail\` where one exists (process view, decision tables, …). The notation-agnostic sibling of get_process: works for every notation with extract+derive capabilities (${NOTATIONS.filter((n) => hasDeriver(n.id)).map((n) => n.id).join(", ")}).`,
1715
+ inputSchema: z.object({
1716
+ id: z.string().describe("Model id = file stem (from list_models), e.g. tea-shop"),
1717
+ notation: z.string().optional().describe("registry notation id — disambiguates a stem shared across notations")
1718
+ }),
1719
+ annotations: READ_ONLY
1720
+ }, safe(async ({ id, notation }) => {
1721
+ if (!config()) return notAContentRepo();
1722
+ const m = await findModel(id, notation);
1723
+ if (!m) return unknownModel(id, notation);
1724
+ const graph = graphOf(m);
1725
+ if (!isGraph(graph)) return graph;
1726
+ const view = deriveView(graph);
1727
+ if (!view) return fail(`a ${nounOf(m.notation)} has no registered deriver — get_view needs the derive capability.`);
1728
+ return ok({
1729
+ id: m.id,
1730
+ path: m.path,
1731
+ ...view
1732
+ });
1733
+ }));
1734
+ server.registerTool("enumerate_paths", {
1735
+ description: "Enumerate the possible start→end paths through a model's flow (cycle-safe, capped) — BPMN sequence flows, Wardley dependencies, … per the notation's graph hints. Each path is the ordered list of element names. Use for walkthroughs ('what are the ways an order can go?'), test-case derivation, and spotting unexpected shortcuts or dead branches.",
1736
+ inputSchema: z.object({
1737
+ id: z.string().describe("Model id = file stem, e.g. order-to-cash"),
1738
+ notation: z.string().optional().describe("registry notation id — disambiguates a stem shared across notations"),
1739
+ max: z.number().int().min(1).max(100).optional().describe("Maximum number of paths to return (default 20)")
1740
+ }),
1741
+ annotations: READ_ONLY
1742
+ }, safe(async ({ id, notation, max }) => {
1743
+ if (!config()) return notAContentRepo();
1744
+ const m = await findModel(id, notation);
1745
+ if (!m) return unknownModel(id, notation);
1746
+ const graph = graphOf(m);
1747
+ if (!isGraph(graph)) return graph;
1748
+ const kinds = flowKindsOf(graph);
1749
+ if (!kinds) return ok(`${pluralOf(m.notation)} have no flow semantics (no graphHints) — nothing to enumerate.`);
1750
+ const { paths, truncated } = enumeratePaths(graph, kinds, max ?? 20);
1751
+ return ok({
1752
+ id: m.id,
1753
+ pathCount: paths.length,
1754
+ truncated,
1755
+ paths,
1756
+ ...paths.length === 0 ? { note: "no start→end chains over the notation's flow edges — isolated or cyclic-only elements are not paths" } : {}
1757
+ });
1758
+ }));
1759
+ server.registerTool("find_cycles", {
1760
+ description: "Detect cycles (loops) in a model's flow — BPMN rework/retry loops, circular Wardley dependencies, circular DMN requirements — per the notation's graph hints. Returns each cycle as the ordered list of element names. Use when analyzing complexity, rework cost, or 'why does this case never finish'.",
1761
+ inputSchema: z.object({
1762
+ id: z.string().describe("Model id = file stem, e.g. order-to-cash"),
1763
+ notation: z.string().optional().describe("registry notation id — disambiguates a stem shared across notations")
1764
+ }),
1765
+ annotations: READ_ONLY
1766
+ }, safe(async ({ id, notation }) => {
1767
+ if (!config()) return notAContentRepo();
1768
+ const m = await findModel(id, notation);
1769
+ if (!m) return unknownModel(id, notation);
1770
+ const graph = graphOf(m);
1771
+ if (!isGraph(graph)) return graph;
1772
+ const kinds = flowKindsOf(graph);
1773
+ if (!kinds) return ok(`${pluralOf(m.notation)} have no flow semantics (no graphHints) — nothing to check.`);
1774
+ const cycles = findCycles(graph, kinds);
1775
+ return ok(cycles.length === 0 ? `No cycles in ${m.id}'s flow.` : {
1776
+ id: m.id,
1777
+ cycles
1778
+ });
1779
+ }));
1780
+ server.registerTool("who_owns", {
1781
+ description: "Resolve ownership of a process from its BPMN lanes — the roles/teams that own its steps (each lane, with the steps it contains) plus the pools (participants). Use for 'who owns X', 'who does what in X', or handoff questions. Note: on the slim contract ownership is whatever the model's lanes say; a process with no lanes has no modeled owner.",
1782
+ inputSchema: z.object({ id: z.string().describe("Process id, e.g. order-to-cash") }),
1783
+ annotations: READ_ONLY
1784
+ }, safe(async ({ id }) => {
1785
+ if (!config()) return notAContentRepo();
1786
+ const proc = await findProcess(id);
1787
+ if (!proc) return unknownProcess(id);
1788
+ const graph = graphOf(proc);
1789
+ if (!isGraph(graph)) return graph;
1790
+ const d = deriveProcess(graph);
1791
+ if (d.roles.length === 0 && d.pools.length === 0) return ok(`Process '${proc.id}' has no lanes or pools — no owning team is modeled in the BPMN.`);
1792
+ const byId = new Map(graph.nodes.map((n) => [n.id, n.name ?? n.id]));
1793
+ return ok({
1794
+ id: proc.id,
1795
+ pools: d.pools,
1796
+ roles: d.roles.map((r) => ({
1797
+ role: r.name,
1798
+ steps: r.stepIds.map((s) => byId.get(s) ?? s)
1799
+ }))
1800
+ });
1801
+ }));
1802
+ server.registerTool("which_processes_use", {
1803
+ description: "Impact analysis across the portfolio: find processes whose id, derived name, role/lane names, step names, or sub-process calls (calledElement) match a query — case-insensitive substring. Use for 'what calls invoice-handling', 'which processes have a Billing lane', 'what touches the credit check'.",
1804
+ inputSchema: z.object({ query: z.string().describe("Case-insensitive substring, e.g. 'invoice-handling', 'Billing', 'credit'") }),
1805
+ annotations: READ_ONLY
1806
+ }, safe(async ({ query }) => {
1807
+ if (!config()) return notAContentRepo();
1808
+ const q = query.toLowerCase();
1809
+ const procs = await processes();
1810
+ const hits = [];
1811
+ for (const proc of procs) {
1812
+ const graph = graphOf(proc);
1813
+ if (!isGraph(graph)) continue;
1814
+ const d = deriveProcess(graph);
1815
+ const matches = [];
1816
+ const check = (field, value) => {
1817
+ if (typeof value === "string" && value.toLowerCase().includes(q)) matches.push({
1818
+ field,
1819
+ value
1820
+ });
1821
+ };
1822
+ check("id", proc.id);
1823
+ check("name", d.name);
1824
+ for (const r of d.roles) check("role", r.name);
1825
+ for (const s of d.steps) check("step", s.name);
1826
+ for (const c of d.calls) check("calls", c.calledElement);
1827
+ if (matches.length > 0) hits.push({
1828
+ id: proc.id,
1829
+ name: d.name ?? proc.id,
1830
+ matches
1831
+ });
1832
+ }
1833
+ if (hits.length === 0) return ok(`No process references '${query}' in its id, name, roles, steps or sub-process calls (checked ${procs.length} process(es)).`);
1834
+ return ok(hits);
1835
+ }));
1836
+ server.registerTool("which_models_use", {
1837
+ description: "Reference-level impact analysis across EVERY notation: all models whose typed cross-model references (callActivity calls, businessRuleTask decides, …) point at the given model id (file stem, exact match) — the repo-wide reference index behind it also flags dangling references. The registry-wide sibling of which_processes_use; use for 'what breaks if I change or delete this model?'.",
1838
+ inputSchema: z.object({ id: z.string().describe("Target model id = file stem (from list_models), e.g. 'credit-check'") }),
1839
+ annotations: READ_ONLY
1840
+ }, safe(async ({ id }) => {
1841
+ const cfg = config();
1842
+ if (!cfg) return notAContentRepo();
1843
+ const index = await buildRepoIndex(root, cfg);
1844
+ const hits = index.refs.filter((r) => r.to.id === id).map((r) => ({
1845
+ from: r.from.path,
1846
+ notation: r.from.notation,
1847
+ ...r.from.element ? { element: r.from.element } : {},
1848
+ rel: r.rel,
1849
+ resolved: r.resolved !== void 0
1850
+ }));
1851
+ if (hits.length === 0) return ok(`No model references '${id}' (checked ${index.refs.length} reference(s) across the repo).`);
1852
+ return ok({
1853
+ target: id,
1854
+ referencedBy: hits
1855
+ });
1856
+ }));
1857
+ if (todos) {
1858
+ const { repo: todosRepo, token: todosToken } = todos;
1859
+ const api = (todos.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
1860
+ server.registerTool("list_todos", {
1861
+ description: "List the OPEN model-anchored todos of this content repo — work items filed from the live model into the repo's issue tracker (label 'todo'). Each row carries the tracker id and URL, title, assignees, createdAt and the parsed anchor (process, model file, anchored BPMN elements). Optional filter on one process. Use for 'what is open on process X' or to cross-check a model answer against known open discrepancies.",
1862
+ inputSchema: z.object({ process: z.string().optional().describe("Only todos anchored to this process id, e.g. order-to-cash") }),
1863
+ annotations: {
1864
+ readOnlyHint: true,
1865
+ openWorldHint: true
1866
+ }
1867
+ }, safe(async ({ process: processId }) => {
1868
+ const res = await fetch(`${api}/repos/${todosRepo}/issues?state=open&labels=${encodeURIComponent(todoLabelQuery(processId))}&per_page=100`, { headers: {
1869
+ accept: "application/vnd.github+json",
1870
+ authorization: `Bearer ${todosToken}`,
1871
+ "user-agent": "designiq-mcp"
1872
+ } });
1873
+ if (!res.ok) return fail(`Tracker query on ${todosRepo} failed: ${res.status} ${await res.text()}`);
1874
+ const todos = (await res.json()).filter((row) => !isPullRequestRow(row)).map((row) => ({
1875
+ id: String(row.number),
1876
+ url: row.html_url,
1877
+ title: row.title,
1878
+ anchor: parseAnchor(row.body ?? ""),
1879
+ assignees: (row.assignees ?? []).map((a) => a.login),
1880
+ createdAt: row.created_at
1881
+ }));
1882
+ if (todos.length === 0) return ok(`No open todos${processId ? ` for process '${processId}'` : ""} in ${todosRepo}.`);
1883
+ return ok(todos);
1884
+ }));
1885
+ }
1886
+ for (const contribute of contributions) contribute(server, root);
1887
+ return server;
1888
+ }
1889
+ //#endregion
1890
+ export { createMcpServer as n, todosConfigFromEnv as r, DEFAULT_ROOT as t };