@unified-product-graph/sdk 0.6.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.
package/dist/index.js ADDED
@@ -0,0 +1,2732 @@
1
+ import {
2
+ BUSINESS_AREA_META,
3
+ DESCRIPTION_SOFT_LIMIT,
4
+ ENTITY_CLASSIFICATION,
5
+ InferEdgeTypeError,
6
+ PROPERTY_TREE_SOFT_BYTES,
7
+ PROPERTY_TREE_SOFT_DEPTH,
8
+ TIER_CLUSTER_META,
9
+ TIER_DESCRIPTIONS,
10
+ TITLE_SOFT_LIMIT,
11
+ UPG_ANTI_PATTERNS,
12
+ UPG_CROSS_EDGE_TYPES,
13
+ UPG_EDGE_CATALOG,
14
+ UPG_REGIONS,
15
+ UPG_TYPES,
16
+ UPG_VERSION,
17
+ UnknownEntityTypeError,
18
+ buildAdjacentEdges,
19
+ buildAlternateAnchors,
20
+ buildAnchorHint,
21
+ buildResolverHints,
22
+ checkLengthCaps,
23
+ checkPropertyTypes,
24
+ classifyDanglingEdges,
25
+ coerceProductStage,
26
+ collectAntiPatternInputs,
27
+ collectSlugsForType,
28
+ computeSchemaDriftSummary,
29
+ edgeId,
30
+ evaluateExpression,
31
+ executeInspect,
32
+ executePlan,
33
+ executePrioritise,
34
+ executeReflect,
35
+ executeTrace,
36
+ generateSlug,
37
+ getClassification,
38
+ getEntitiesForBusinessArea,
39
+ getEntitiesForCluster,
40
+ getEntitiesForTier,
41
+ getEntitiesForTierAndArea,
42
+ getEntitiesUpToTier,
43
+ getLifecycleForType,
44
+ getReplacementType,
45
+ getTierEntityCount,
46
+ getTierForStage,
47
+ inferEdgeType,
48
+ inferEdgeTypeWithTier,
49
+ isRelevantForStage,
50
+ migrateEdge,
51
+ migrateNodeProperties,
52
+ nodeId,
53
+ productId,
54
+ renderDanglingReport,
55
+ renderDriftSummary,
56
+ renderPropertyTypeWarning,
57
+ resolveEntityType,
58
+ resolveSlugCollision,
59
+ rotateSlug,
60
+ validateClassificationCoverage,
61
+ validateEdgeTypePair,
62
+ validateProductStageStrict,
63
+ validateUPGDocument
64
+ } from "./chunk-ARDXTSGG.js";
65
+
66
+ // src/store.ts
67
+ import * as fs from "fs/promises";
68
+ import * as path from "path";
69
+ import { createHash } from "crypto";
70
+ var UPGFileStore = class {
71
+ doc;
72
+ filePath;
73
+ dirty = false;
74
+ saveTimer = null;
75
+ selfWriteInProgress = false;
76
+ watcher = null;
77
+ // Indexes for O(1) lookups
78
+ nodeMap = /* @__PURE__ */ new Map();
79
+ edgeMap = /* @__PURE__ */ new Map();
80
+ edgesByNode = /* @__PURE__ */ new Map();
81
+ // nodeId → Set<edgeId>
82
+ // Session change log
83
+ changeLog = [];
84
+ // Content hash for cache-aware responses
85
+ contentHash = "";
86
+ // ── Concurrent write protection ─────────────────────────────────────────
87
+ // Baseline = the raw file hash at the time we loaded or last saved.
88
+ // If disk hash != baseline when we try to save, another process modified the file.
89
+ baselineFileHash = "";
90
+ // Snapshot of node/edge IDs at baseline — used for three-way merge
91
+ baselineNodeIds = /* @__PURE__ */ new Set();
92
+ baselineEdgeIds = /* @__PURE__ */ new Set();
93
+ // Last merge result (available to the server for reporting)
94
+ lastMergeResult = null;
95
+ // ── Integrity protection ───────────────────────────────────────────────
96
+ // Last integrity check result (available to the server for reporting)
97
+ lastIntegrityReport = null;
98
+ // Set of known UPG entity types for schema validation
99
+ knownTypes = new Set(UPG_TYPES);
100
+ // ── Dangling-edge tracking ───────────────────────────────────
101
+ lastDanglingReport = null;
102
+ lastDriftSummary = null;
103
+ // ── Load / Save ──────────────────────────────────────────────────────────
104
+ /** Hash raw file bytes — used for baseline comparison (NOT the same as contentHash) */
105
+ hashRawContent(raw) {
106
+ return createHash("sha256").update(raw).digest("hex").slice(0, 32);
107
+ }
108
+ /** Compute integrity checksum over nodes + edges content (deterministic) */
109
+ computeIntegrityChecksum() {
110
+ const sortedNodes = [...this.doc.nodes].sort((a, b) => a.id.localeCompare(b.id));
111
+ const sortedEdges = [...this.doc.edges].sort((a, b) => a.id.localeCompare(b.id));
112
+ const content = JSON.stringify({ nodes: sortedNodes, edges: sortedEdges });
113
+ return createHash("sha256").update(content).digest("hex").slice(0, 32);
114
+ }
115
+ /** Stamp the document with current integrity checksum */
116
+ stampIntegrity() {
117
+ this.doc._integrity = {
118
+ checksum: this.computeIntegrityChecksum(),
119
+ verified_at: (/* @__PURE__ */ new Date()).toISOString(),
120
+ verified_by: "upg-mcp-local"
121
+ };
122
+ }
123
+ /** Verify integrity and quarantine invalid entities if file was modified externally */
124
+ verifyIntegrity() {
125
+ const report = { tampered: false, quarantined: [], orphanedEdges: 0 };
126
+ if (!this.doc._integrity) {
127
+ this.stampIntegrity();
128
+ return report;
129
+ }
130
+ const currentChecksum = this.computeIntegrityChecksum();
131
+ if (currentChecksum === this.doc._integrity.checksum) {
132
+ return report;
133
+ }
134
+ report.tampered = true;
135
+ const validNodeIds = /* @__PURE__ */ new Set();
136
+ const quarantinedIds = /* @__PURE__ */ new Set();
137
+ this.doc.nodes = this.doc.nodes.filter((node) => {
138
+ if (!node.id || !node.type || !node.title) {
139
+ report.quarantined.push({
140
+ id: node.id || "unknown",
141
+ type: node.type || "unknown",
142
+ title: node.title || "untitled",
143
+ reason: "Missing required field (id, type, or title)"
144
+ });
145
+ quarantinedIds.add(node.id || "unknown");
146
+ return false;
147
+ }
148
+ if (!this.knownTypes.has(node.type) && node.type !== "product" && node.type !== "document") {
149
+ report.quarantined.push({
150
+ id: node.id,
151
+ type: node.type,
152
+ title: node.title,
153
+ reason: `Unknown entity type: "${node.type}"`
154
+ });
155
+ quarantinedIds.add(node.id);
156
+ return false;
157
+ }
158
+ validNodeIds.add(node.id);
159
+ return true;
160
+ });
161
+ const beforeEdgeCount = this.doc.edges.length;
162
+ this.doc.edges = this.doc.edges.filter((edge) => {
163
+ if (!edge.id || !edge.source || !edge.target || !edge.type) return false;
164
+ return validNodeIds.has(edge.source) && validNodeIds.has(edge.target);
165
+ });
166
+ report.orphanedEdges = beforeEdgeCount - this.doc.edges.length;
167
+ if (report.quarantined.length > 0 || report.orphanedEdges > 0) {
168
+ this.stampIntegrity();
169
+ this.dirty = true;
170
+ } else {
171
+ this.stampIntegrity();
172
+ this.dirty = true;
173
+ }
174
+ return report;
175
+ }
176
+ getIntegrityReport() {
177
+ return this.lastIntegrityReport;
178
+ }
179
+ /** Snapshot current node/edge IDs as baseline for three-way merge */
180
+ snapshotBaseline() {
181
+ this.baselineNodeIds = new Set(this.doc.nodes.map((n) => n.id));
182
+ this.baselineEdgeIds = new Set(this.doc.edges.map((e) => e.id));
183
+ }
184
+ getLastMergeResult() {
185
+ return this.lastMergeResult;
186
+ }
187
+ async load(filePath) {
188
+ this.filePath = path.resolve(filePath);
189
+ const raw = await fs.readFile(this.filePath, "utf-8");
190
+ const parsed = JSON.parse(raw);
191
+ const result = validateUPGDocument(parsed);
192
+ if (!result.valid) {
193
+ const msgs = result.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n");
194
+ throw new Error(`Invalid UPG document:
195
+ ${msgs}`);
196
+ }
197
+ this.doc = parsed;
198
+ if (this.doc.product?.stage !== void 0) {
199
+ const coercion = coerceProductStage(this.doc.product.stage);
200
+ if (coercion.wasCoerced && coercion.canonical) {
201
+ process.stderr.write(
202
+ `[product-stage] Product stage ${JSON.stringify(coercion.originalValue)} is not a canonical UPGProductStage. Coerced in-memory to ${JSON.stringify(coercion.canonical)}. Run \`migrate_properties\` (or update the file) to persist the canonical value. File: ${this.filePath}
203
+ `
204
+ );
205
+ this.doc = {
206
+ ...this.doc,
207
+ product: { ...this.doc.product, stage: coercion.canonical }
208
+ };
209
+ } else if (coercion.wasUnknown) {
210
+ process.stderr.write(
211
+ `[product-stage] Product stage ${JSON.stringify(coercion.originalValue)} is not a canonical UPGProductStage and has no documented coercion target. Treating as missing in lifecycle calculations. Set a canonical value via \`update_node\` to clear this warning. File: ${this.filePath}
212
+ `
213
+ );
214
+ }
215
+ }
216
+ this.lastIntegrityReport = this.verifyIntegrity();
217
+ this.rebuildIndexes();
218
+ this.computeHash();
219
+ this.baselineFileHash = this.hashRawContent(raw);
220
+ this.snapshotBaseline();
221
+ this.lastDanglingReport = classifyDanglingEdges(
222
+ this.doc.edges,
223
+ new Set(this.doc.nodes.map((n) => n.id))
224
+ );
225
+ const rendered = renderDanglingReport(this.lastDanglingReport, this.filePath);
226
+ if (rendered) process.stderr.write(rendered + "\n");
227
+ this.lastDriftSummary = computeSchemaDriftSummary(this.doc);
228
+ const driftRendered = renderDriftSummary(this.lastDriftSummary, this.filePath);
229
+ if (driftRendered) process.stderr.write(driftRendered + "\n");
230
+ await this.startWatching();
231
+ }
232
+ /**
233
+ * Snapshot of the dangling-edge classification computed at load time.
234
+ * Returns null until `load()` has run.
235
+ */
236
+ getDanglingReport() {
237
+ return this.lastDanglingReport;
238
+ }
239
+ /**
240
+ * Snapshot of the schema-drift summary computed at load time.
241
+ * Counts only — full per-node breakdown is `validate_graph`.
242
+ * Returns null until `load()` has run.
243
+ */
244
+ getDriftSummary() {
245
+ return this.lastDriftSummary;
246
+ }
247
+ /**
248
+ * Drop edges matching the given dangling classes from the document. Used by
249
+ * the `repair_dangling_edges` tool with `dry_run: false`. Caller is
250
+ * responsible for choosing classes — this method does not protect
251
+ * `expected` cross-product edges by default; pass an empty array to no-op.
252
+ */
253
+ dropDanglingEdges(classes) {
254
+ if (classes.length === 0) {
255
+ return { dropped: 0, remaining: this.lastDanglingReport ?? { total: 0, by_class: { expected: 0, suspect: 0, corrupt: 0 }, edges: [] } };
256
+ }
257
+ const report = classifyDanglingEdges(
258
+ this.doc.edges,
259
+ new Set(this.doc.nodes.map((n) => n.id))
260
+ );
261
+ const targetClasses = new Set(classes);
262
+ const dropIds = new Set(
263
+ report.edges.filter((e) => targetClasses.has(e.class)).map((e) => e.id)
264
+ );
265
+ if (dropIds.size === 0) {
266
+ return { dropped: 0, remaining: report };
267
+ }
268
+ this.doc.edges = this.doc.edges.filter((e) => !dropIds.has(e.id));
269
+ this.rebuildIndexes();
270
+ this.dirty = true;
271
+ this.computeHash();
272
+ this.lastDanglingReport = classifyDanglingEdges(
273
+ this.doc.edges,
274
+ new Set(this.doc.nodes.map((n) => n.id))
275
+ );
276
+ return { dropped: dropIds.size, remaining: this.lastDanglingReport };
277
+ }
278
+ async save() {
279
+ if (!this.dirty) return;
280
+ let diskRaw;
281
+ try {
282
+ diskRaw = await fs.readFile(this.filePath, "utf-8");
283
+ } catch {
284
+ diskRaw = "";
285
+ }
286
+ const diskHash = diskRaw ? this.hashRawContent(diskRaw) : "";
287
+ if (diskHash && diskHash !== this.baselineFileHash) {
288
+ this.lastMergeResult = await this.mergeWithDisk(diskRaw);
289
+ if (this.lastMergeResult.conflicts.length > 0) {
290
+ const conflictDesc = this.lastMergeResult.conflicts.map((c) => ` Node ${c.nodeId}: field "${c.field}" \u2014 ours: ${JSON.stringify(c.ours)}, theirs: ${JSON.stringify(c.theirs)}`).join("\n");
291
+ throw new Error(
292
+ `CONFLICT: The .upg file was modified by another session.
293
+ Nodes added by other session: ${this.lastMergeResult.nodesFromDisk}
294
+ Edges added by other session: ${this.lastMergeResult.edgesFromDisk}
295
+ Conflicts (same node modified differently):
296
+ ${conflictDesc}
297
+
298
+ Auto-merge failed. Run the save again after resolving conflicts, or reload the file.`
299
+ );
300
+ }
301
+ this.rebuildIndexes();
302
+ }
303
+ this.doc.exported_at = (/* @__PURE__ */ new Date()).toISOString();
304
+ if (!this.doc.source.tool) {
305
+ this.doc.source.tool = "upg-mcp-local";
306
+ }
307
+ this.stampIntegrity();
308
+ const output = JSON.stringify(this.doc, null, 2) + "\n";
309
+ const tmpPath = this.filePath + ".tmp";
310
+ this.selfWriteInProgress = true;
311
+ try {
312
+ await fs.writeFile(tmpPath, output, "utf-8");
313
+ await fs.rename(tmpPath, this.filePath);
314
+ this.dirty = false;
315
+ this.computeHash();
316
+ this.baselineFileHash = this.hashRawContent(output);
317
+ this.snapshotBaseline();
318
+ } finally {
319
+ setTimeout(() => {
320
+ this.selfWriteInProgress = false;
321
+ }, 150);
322
+ }
323
+ }
324
+ // ── Three-Way Merge ───────────────────────────────────────────────────────
325
+ //
326
+ // Three states:
327
+ // baseline = what was on disk when we loaded (or last saved)
328
+ // disk = what's on disk now (another session wrote this)
329
+ // ours = our in-memory state
330
+ //
331
+ // Strategy:
332
+ // - Nodes/edges in disk but not in baseline → added by other session → keep
333
+ // - Nodes/edges in ours but not in baseline → added by this session → keep
334
+ // - Nodes/edges in baseline but not in disk → deleted by other session → accept deletion
335
+ // - Nodes/edges in baseline but not in ours → deleted by this session → accept deletion
336
+ // - Nodes in both disk and ours with different content → CONFLICT
337
+ //
338
+ async mergeWithDisk(diskRaw) {
339
+ let diskDoc;
340
+ try {
341
+ const parsed = JSON.parse(diskRaw);
342
+ if (!validateUPGDocument(parsed).valid) {
343
+ return { merged: true, nodesAdded: 0, edgesAdded: 0, nodesFromDisk: 0, edgesFromDisk: 0, conflicts: [] };
344
+ }
345
+ diskDoc = parsed;
346
+ } catch {
347
+ return { merged: true, nodesAdded: 0, edgesAdded: 0, nodesFromDisk: 0, edgesFromDisk: 0, conflicts: [] };
348
+ }
349
+ const diskNodeMap = new Map(diskDoc.nodes.map((n) => [n.id, n]));
350
+ const diskEdgeMap = new Map(diskDoc.edges.map((e) => [e.id, e]));
351
+ const ourNodeMap = this.nodeMap;
352
+ const ourEdgeMap = this.edgeMap;
353
+ const conflicts = [];
354
+ let nodesFromDisk = 0;
355
+ let edgesFromDisk = 0;
356
+ for (const [id, diskNode] of diskNodeMap) {
357
+ if (!this.baselineNodeIds.has(id)) {
358
+ if (!ourNodeMap.has(id)) {
359
+ this.doc.nodes.push(diskNode);
360
+ nodesFromDisk++;
361
+ }
362
+ } else if (ourNodeMap.has(id)) {
363
+ const ourNode = ourNodeMap.get(id);
364
+ const ourModified = ourNode.title !== diskNode.title || ourNode.status !== diskNode.status;
365
+ if (ourModified) {
366
+ if (ourNode.title !== diskNode.title) {
367
+ conflicts.push({ nodeId: id, field: "title", ours: ourNode.title, theirs: diskNode.title });
368
+ }
369
+ if (ourNode.status !== diskNode.status) {
370
+ conflicts.push({ nodeId: id, field: "status", ours: ourNode.status, theirs: diskNode.status });
371
+ }
372
+ }
373
+ }
374
+ }
375
+ for (const [id, diskEdge] of diskEdgeMap) {
376
+ if (!this.baselineEdgeIds.has(id)) {
377
+ if (!ourEdgeMap.has(id)) {
378
+ const sourceExists = ourNodeMap.has(diskEdge.source) || diskNodeMap.has(diskEdge.source);
379
+ const targetExists = ourNodeMap.has(diskEdge.target) || diskNodeMap.has(diskEdge.target);
380
+ if (sourceExists && targetExists) {
381
+ this.doc.edges.push(diskEdge);
382
+ edgesFromDisk++;
383
+ }
384
+ }
385
+ }
386
+ }
387
+ for (const id of this.baselineNodeIds) {
388
+ if (!diskNodeMap.has(id) && ourNodeMap.has(id)) {
389
+ const ourNode = ourNodeMap.get(id);
390
+ const weModified = this.changeLog.some(
391
+ (c) => c.id === id && c.entity === "node" && c.action === "update"
392
+ );
393
+ if (!weModified) {
394
+ this.doc.nodes = this.doc.nodes.filter((n) => n.id !== id);
395
+ this.doc.edges = this.doc.edges.filter(
396
+ (e) => e.source !== id && e.target !== id
397
+ );
398
+ }
399
+ }
400
+ }
401
+ return {
402
+ merged: conflicts.length === 0,
403
+ nodesAdded: nodesFromDisk,
404
+ edgesAdded: edgesFromDisk,
405
+ nodesFromDisk,
406
+ edgesFromDisk,
407
+ conflicts
408
+ };
409
+ }
410
+ async flush() {
411
+ if (this.saveTimer) {
412
+ clearTimeout(this.saveTimer);
413
+ this.saveTimer = null;
414
+ }
415
+ await this.save();
416
+ }
417
+ /**
418
+ * Mark the store as dirty so the next `flush()` or `save()` writes to disk.
419
+ * Use this when an external caller mutates the document object directly (e.g.
420
+ * the cross-edge migration which modifies `doc.edges` in-place via
421
+ * `UPGPortfolioStore.migrateCrossEdgesFromDoc`).
422
+ */
423
+ markDirty() {
424
+ this.dirty = true;
425
+ }
426
+ scheduleSave() {
427
+ this.dirty = true;
428
+ if (this.saveTimer) clearTimeout(this.saveTimer);
429
+ this.saveTimer = setTimeout(() => this.save(), 300);
430
+ }
431
+ // ── File Watching ────────────────────────────────────────────────────────
432
+ async startWatching() {
433
+ if (this.watcher) return;
434
+ const { watch } = await import("chokidar");
435
+ this.watcher = watch(this.filePath, {
436
+ persistent: false,
437
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }
438
+ });
439
+ this.watcher.on("change", async () => {
440
+ if (this.selfWriteInProgress) return;
441
+ try {
442
+ const raw = await fs.readFile(this.filePath, "utf-8");
443
+ const parsed = JSON.parse(raw);
444
+ if (!validateUPGDocument(parsed).valid) return;
445
+ if (this.dirty) {
446
+ const result = await this.mergeWithDisk(raw);
447
+ this.lastMergeResult = result;
448
+ if (result.conflicts.length === 0 && (result.nodesFromDisk > 0 || result.edgesFromDisk > 0)) {
449
+ this.rebuildIndexes();
450
+ this.computeHash();
451
+ this.baselineFileHash = this.hashRawContent(raw);
452
+ }
453
+ } else {
454
+ this.doc = parsed;
455
+ this.rebuildIndexes();
456
+ this.computeHash();
457
+ this.baselineFileHash = this.hashRawContent(raw);
458
+ this.snapshotBaseline();
459
+ this.dirty = false;
460
+ }
461
+ } catch {
462
+ }
463
+ });
464
+ }
465
+ stopWatching() {
466
+ this.watcher?.close();
467
+ this.watcher = null;
468
+ }
469
+ // ── Index Management ─────────────────────────────────────────────────────
470
+ rebuildIndexes() {
471
+ this.nodeMap.clear();
472
+ this.edgeMap.clear();
473
+ this.edgesByNode.clear();
474
+ for (const node of this.doc.nodes) {
475
+ this.nodeMap.set(node.id, node);
476
+ }
477
+ for (const edge of this.doc.edges) {
478
+ this.edgeMap.set(edge.id, edge);
479
+ this.indexEdgeForNode(edge);
480
+ }
481
+ }
482
+ indexEdgeForNode(edge) {
483
+ for (const nodeId2 of [edge.source, edge.target]) {
484
+ let set = this.edgesByNode.get(nodeId2);
485
+ if (!set) {
486
+ set = /* @__PURE__ */ new Set();
487
+ this.edgesByNode.set(nodeId2, set);
488
+ }
489
+ set.add(edge.id);
490
+ }
491
+ }
492
+ unindexEdgeForNode(edge) {
493
+ this.edgesByNode.get(edge.source)?.delete(edge.id);
494
+ this.edgesByNode.get(edge.target)?.delete(edge.id);
495
+ }
496
+ // ── Hash ─────────────────────────────────────────────────────────────────
497
+ computeHash() {
498
+ const content = JSON.stringify({
499
+ nodes: this.doc.nodes.length,
500
+ edges: this.doc.edges.length,
501
+ nodeIds: this.doc.nodes.map((n) => n.id).sort(),
502
+ edgeIds: this.doc.edges.map((e) => e.id).sort(),
503
+ lastMod: this.doc.exported_at
504
+ });
505
+ this.contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
506
+ }
507
+ getContentHash() {
508
+ return this.contentHash;
509
+ }
510
+ // ── Reads ────────────────────────────────────────────────────────────────
511
+ getFilePath() {
512
+ return this.filePath;
513
+ }
514
+ getDocument() {
515
+ return this.doc;
516
+ }
517
+ getProduct() {
518
+ return this.doc.product;
519
+ }
520
+ getNode(id) {
521
+ return this.nodeMap.get(id);
522
+ }
523
+ getEdge(id) {
524
+ return this.edgeMap.get(id);
525
+ }
526
+ getAllNodes() {
527
+ return this.doc.nodes;
528
+ }
529
+ getAllEdges() {
530
+ return this.doc.edges;
531
+ }
532
+ getEdgesForNode(nodeId2) {
533
+ const edgeIds = this.edgesByNode.get(nodeId2);
534
+ if (!edgeIds) return [];
535
+ return [...edgeIds].map((id) => this.edgeMap.get(id)).filter(Boolean);
536
+ }
537
+ // ── Change Log ──────────────────────────────────────────────────────────
538
+ logChange(action, entity, id, type, title) {
539
+ this.changeLog.push({
540
+ action,
541
+ entity,
542
+ id,
543
+ type,
544
+ title,
545
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
546
+ });
547
+ }
548
+ getChanges(since) {
549
+ if (!since) return [...this.changeLog];
550
+ return this.changeLog.filter((c) => c.timestamp >= since);
551
+ }
552
+ // ── Writes ───────────────────────────────────────────────────────────────
553
+ addNode(node) {
554
+ this.doc.nodes.push(node);
555
+ this.nodeMap.set(node.id, node);
556
+ this.logChange("create", "node", node.id, node.type, node.title);
557
+ this.scheduleSave();
558
+ }
559
+ updateNode(id, patch) {
560
+ const node = this.nodeMap.get(id);
561
+ if (!node) throw new Error(`Node not found: ${id}`);
562
+ if (patch.type !== void 0) node.type = patch.type;
563
+ if (patch.title !== void 0) node.title = patch.title;
564
+ if (patch.description !== void 0) node.description = patch.description;
565
+ if (patch.tags !== void 0) node.tags = patch.tags;
566
+ if (patch.status !== void 0) node.status = patch.status;
567
+ if (patch.slug !== void 0 && patch.slug !== node.slug) {
568
+ rotateSlug(node, patch.slug);
569
+ }
570
+ if (patch.aliases !== void 0) node.aliases = patch.aliases;
571
+ if (patch.properties) {
572
+ node.properties = { ...node.properties ?? {}, ...patch.properties };
573
+ }
574
+ this.logChange("update", "node", node.id, node.type, node.title);
575
+ this.scheduleSave();
576
+ return node;
577
+ }
578
+ removeNode(id) {
579
+ const node = this.nodeMap.get(id);
580
+ if (!node) throw new Error(`Node not found: ${id}`);
581
+ const edgeIds = new Set(this.edgesByNode.get(id) ?? []);
582
+ const removedEdgeIds = [];
583
+ for (const edgeId2 of edgeIds) {
584
+ const edge = this.edgeMap.get(edgeId2);
585
+ if (edge) {
586
+ this.unindexEdgeForNode(edge);
587
+ this.edgeMap.delete(edgeId2);
588
+ removedEdgeIds.push(edgeId2);
589
+ }
590
+ }
591
+ this.doc.edges = this.doc.edges.filter((e) => !edgeIds.has(e.id));
592
+ this.edgesByNode.delete(id);
593
+ this.doc.nodes = this.doc.nodes.filter((n) => n.id !== id);
594
+ this.nodeMap.delete(id);
595
+ this.logChange("delete", "node", node.id, node.type, node.title);
596
+ for (const eid of removedEdgeIds) {
597
+ this.logChange("delete", "edge", eid, "cascade", void 0);
598
+ }
599
+ this.scheduleSave();
600
+ return { node, removedEdgeIds };
601
+ }
602
+ addEdge(edge, skipValidation = false) {
603
+ if (!skipValidation) {
604
+ if (!this.nodeMap.has(edge.source))
605
+ throw new Error(`Source node not found: ${edge.source}`);
606
+ if (!this.nodeMap.has(edge.target))
607
+ throw new Error(`Target node not found: ${edge.target}`);
608
+ }
609
+ this.doc.edges.push(edge);
610
+ this.edgeMap.set(edge.id, edge);
611
+ this.indexEdgeForNode(edge);
612
+ this.logChange("create", "edge", edge.id, edge.type, void 0);
613
+ this.scheduleSave();
614
+ }
615
+ removeEdge(id) {
616
+ const edge = this.edgeMap.get(id);
617
+ if (!edge) throw new Error(`Edge not found: ${id}`);
618
+ this.unindexEdgeForNode(edge);
619
+ this.edgeMap.delete(id);
620
+ this.doc.edges = this.doc.edges.filter((e) => e.id !== id);
621
+ this.logChange("delete", "edge", edge.id, edge.type, void 0);
622
+ this.scheduleSave();
623
+ return edge;
624
+ }
625
+ migrateType(fromType, toType, defaults) {
626
+ let migratedNodes = 0;
627
+ for (const node of this.doc.nodes) {
628
+ if (node.type === fromType) {
629
+ node.type = toType;
630
+ if (defaults && Object.keys(defaults).length > 0) {
631
+ node.properties = { ...defaults, ...node.properties ?? {} };
632
+ }
633
+ migratedNodes++;
634
+ }
635
+ }
636
+ const edgeResult = this.applyEdgeMigrations("0.0.0", UPG_VERSION);
637
+ this.scheduleSave();
638
+ return {
639
+ migratedNodes,
640
+ edgeRenames: edgeResult.renamed,
641
+ edgeDrops: edgeResult.dropped
642
+ };
643
+ }
644
+ /**
645
+ * Exact-match rename of every edge whose `type === from` to `to`. Optionally
646
+ * flips `source`/`target` for each affected edge. The catalog is intentionally
647
+ * NOT consulted here — this is the low-level primitive backing
648
+ * `rename_edge_type`. Catalog awareness lives in the wrappers
649
+ * tracked separately.
650
+ *
651
+ * Returns the IDs of every edge that was actually mutated. The internal
652
+ * `edgesByNode` index is keyed by node id, so a flip does not require
653
+ * re-indexing — both endpoints are already tracked for the same edge id.
654
+ */
655
+ renameEdgeType(from, to, flip = false) {
656
+ const ids = [];
657
+ for (const edge of this.doc.edges) {
658
+ if (edge.type !== from) continue;
659
+ edge.type = to;
660
+ if (flip) {
661
+ const oldSource = edge.source;
662
+ edge.source = edge.target;
663
+ edge.target = oldSource;
664
+ }
665
+ this.logChange("update", "edge", edge.id, edge.type, void 0);
666
+ ids.push(edge.id);
667
+ }
668
+ if (ids.length > 0) this.scheduleSave();
669
+ return { renamed: ids.length, ids };
670
+ }
671
+ /**
672
+ * Apply every applicable rule from `UPG_EDGE_MIGRATIONS` (the v0.2.4
673
+ * canonical edge registry) to the loaded graph.
674
+ * Renames retarget the edge type; flipped renames swap source/target;
675
+ * dropped edges are removed entirely. Endpoint guards in the migration
676
+ * rules check post-migration node types — so callers should run any
677
+ * needed `migrateType` / `applySplit` pass on nodes BEFORE calling this.
678
+ *
679
+ * Wave 3 of the MCP edge-primitives cascade.
680
+ */
681
+ applyEdgeMigrations(fromVersion, toVersion) {
682
+ const renamed = [];
683
+ const dropped = [];
684
+ const edgeIds = this.doc.edges.map((e) => e.id);
685
+ for (const id of edgeIds) {
686
+ const edge = this.edgeMap.get(id);
687
+ if (!edge) continue;
688
+ const sourceNode = this.nodeMap.get(edge.source);
689
+ const targetNode = this.nodeMap.get(edge.target);
690
+ const result = migrateEdge(edge, fromVersion, toVersion, {
691
+ sourceType: sourceNode?.type,
692
+ targetType: targetNode?.type
693
+ });
694
+ if (result === null) {
695
+ dropped.push({ id, from: edge.type });
696
+ this.removeEdge(id);
697
+ continue;
698
+ }
699
+ if (result === edge) continue;
700
+ const oldType = edge.type;
701
+ const flipped = result.source !== edge.source;
702
+ edge.type = result.type;
703
+ if (flipped) {
704
+ edge.source = result.source;
705
+ edge.target = result.target;
706
+ }
707
+ this.logChange("update", "edge", edge.id, edge.type, void 0);
708
+ renamed.push({ id: edge.id, from: oldType, to: edge.type, flipped });
709
+ }
710
+ if (renamed.length > 0 || dropped.length > 0) this.scheduleSave();
711
+ return { renamed, dropped };
712
+ }
713
+ applyPropertyMigrations(fromVersion, toVersion) {
714
+ const top_level_renames = [];
715
+ const lifted_properties = [];
716
+ const dropped_props = [];
717
+ const dropped_self_referential = [];
718
+ let mutatedAny = false;
719
+ for (let i = 0; i < this.doc.nodes.length; i++) {
720
+ const original = this.doc.nodes[i];
721
+ const { node: migrated, changes } = migrateNodeProperties(
722
+ original,
723
+ fromVersion,
724
+ toVersion
725
+ );
726
+ if (changes.length === 0) continue;
727
+ for (const change of changes) {
728
+ switch (change.kind) {
729
+ case "dropped":
730
+ dropped_props.push({ id: original.id, key: change.key });
731
+ break;
732
+ case "renamed_top_level":
733
+ top_level_renames.push({ id: original.id, from: change.from, to: change.to, value_changed: change.value_changed });
734
+ break;
735
+ case "lifted_to_top_level":
736
+ lifted_properties.push({ id: original.id, from_property: change.from_property, to: change.to, value_changed: change.value_changed });
737
+ break;
738
+ case "self_ref_dropped":
739
+ dropped_self_referential.push({ id: original.id, field: change.field });
740
+ break;
741
+ }
742
+ }
743
+ const migratedNode = migrated;
744
+ this.doc.nodes[i] = migratedNode;
745
+ this.nodeMap.set(migratedNode.id, migratedNode);
746
+ this.logChange("update", "node", migratedNode.id, migratedNode.type, void 0);
747
+ mutatedAny = true;
748
+ }
749
+ if (mutatedAny) this.scheduleSave();
750
+ return { top_level_renames, lifted_properties, dropped_props, dropped_self_referential };
751
+ }
752
+ };
753
+ var UPGPortfolioStore = class {
754
+ doc = null;
755
+ filePath = null;
756
+ dirty = false;
757
+ saveTimer = null;
758
+ // ── Load / Save ─────────────────────────────────────────────────────────────
759
+ /**
760
+ * Load an existing portfolio document from disk, or initialise an empty one
761
+ * at the given path if it does not exist.
762
+ *
763
+ * @param filePath Absolute path to the `.portfolio.upg` file.
764
+ * @param orgTitle Organisation title for newly created portfolios.
765
+ */
766
+ async loadOrInit(filePath, orgTitle = "Portfolio") {
767
+ this.filePath = path.resolve(filePath);
768
+ let raw;
769
+ try {
770
+ raw = await fs.readFile(this.filePath, "utf-8");
771
+ } catch (err) {
772
+ if (err.code !== "ENOENT") throw err;
773
+ this.doc = this.makeEmptyPortfolio(orgTitle);
774
+ this.dirty = true;
775
+ await this.flush();
776
+ return {
777
+ cross_edge_count: 0,
778
+ product_count: 0,
779
+ file_path: this.filePath
780
+ };
781
+ }
782
+ const parsed = JSON.parse(raw);
783
+ if (parsed.type !== "portfolio") {
784
+ throw new Error(
785
+ `Expected a portfolio document (type: "portfolio") at ${this.filePath}, but found type: "${parsed.type ?? "unknown"}"`
786
+ );
787
+ }
788
+ this.doc = parsed;
789
+ return {
790
+ cross_edge_count: this.doc.cross_edges.length,
791
+ product_count: this.doc.products.length,
792
+ file_path: this.filePath
793
+ };
794
+ }
795
+ /** Create a minimal valid UPGPortfolioDocument. */
796
+ makeEmptyPortfolio(orgTitle) {
797
+ return {
798
+ upg_version: UPG_VERSION,
799
+ type: "portfolio",
800
+ exported_at: (/* @__PURE__ */ new Date()).toISOString(),
801
+ source: { tool: "upg-mcp-local" },
802
+ organization: {
803
+ id: `org_${createHash("sha256").update(orgTitle).digest("hex").slice(0, 8)}`,
804
+ title: orgTitle
805
+ },
806
+ product_areas: [],
807
+ portfolios: [],
808
+ products: [],
809
+ cross_edges: []
810
+ };
811
+ }
812
+ /** Return the resolved portfolio file path, or null if not loaded. */
813
+ getFilePath() {
814
+ return this.filePath;
815
+ }
816
+ /** Return the loaded portfolio document, or null if not loaded. */
817
+ getDocument() {
818
+ return this.doc;
819
+ }
820
+ /** True when a portfolio document is loaded and ready. */
821
+ isLoaded() {
822
+ return this.doc !== null && this.filePath !== null;
823
+ }
824
+ /** Flush pending writes to disk. No-op if not dirty. */
825
+ async flush() {
826
+ if (this.saveTimer) {
827
+ clearTimeout(this.saveTimer);
828
+ this.saveTimer = null;
829
+ }
830
+ await this.writeToDisk();
831
+ }
832
+ /**
833
+ * Mark the portfolio store as dirty so the next `flush()` writes to disk.
834
+ * Use this when an external caller mutates the document object directly via
835
+ * `getDocument()` rather than going through `addCrossEdge` /
836
+ * `removeCrossEdge`. Mirrors `UPGFileStore.markDirty()`.
837
+ */
838
+ markDirty() {
839
+ this.dirty = true;
840
+ }
841
+ scheduleSave() {
842
+ this.dirty = true;
843
+ if (this.saveTimer) clearTimeout(this.saveTimer);
844
+ this.saveTimer = setTimeout(() => void this.writeToDisk(), 300);
845
+ }
846
+ async writeToDisk() {
847
+ if (!this.dirty || !this.doc || !this.filePath) return;
848
+ this.doc.exported_at = (/* @__PURE__ */ new Date()).toISOString();
849
+ const output = JSON.stringify(this.doc, null, 2) + "\n";
850
+ const tmpPath = this.filePath + ".tmp";
851
+ await fs.writeFile(tmpPath, output, "utf-8");
852
+ await fs.rename(tmpPath, this.filePath);
853
+ this.dirty = false;
854
+ }
855
+ // ── Cross-edge writes ────────────────────────────────────────────────────────
856
+ /**
857
+ * Add a cross-product edge to the portfolio document. Both `source` and
858
+ * `target` must be qualified IDs (`{product_id}/{node_id}`).
859
+ */
860
+ addCrossEdge(edge) {
861
+ if (!this.doc) throw new Error("Portfolio document not loaded. Call loadOrInit() first.");
862
+ if (!edge.id) throw new Error("Cross-edge must have an id");
863
+ if (!edge.source.includes("/")) {
864
+ throw new Error(
865
+ `Cross-edge source must be a qualified ID ({product_id}/{node_id}), got: "${edge.source}"`
866
+ );
867
+ }
868
+ if (!edge.target.includes("/")) {
869
+ throw new Error(
870
+ `Cross-edge target must be a qualified ID ({product_id}/{node_id}), got: "${edge.target}"`
871
+ );
872
+ }
873
+ if (!UPG_CROSS_EDGE_TYPES.includes(edge.type)) {
874
+ throw new Error(
875
+ `Invalid cross-product edge type: "${edge.type}". Valid types: ${UPG_CROSS_EDGE_TYPES.join(", ")}`
876
+ );
877
+ }
878
+ this.doc.cross_edges.push(edge);
879
+ this.scheduleSave();
880
+ }
881
+ /**
882
+ * Remove a cross-product edge by ID.
883
+ * @returns The removed edge, or null if not found.
884
+ */
885
+ removeCrossEdge(edgeId2) {
886
+ if (!this.doc) throw new Error("Portfolio document not loaded.");
887
+ const idx = this.doc.cross_edges.findIndex((e) => e.id === edgeId2);
888
+ if (idx === -1) return null;
889
+ const [removed] = this.doc.cross_edges.splice(idx, 1);
890
+ this.scheduleSave();
891
+ return removed;
892
+ }
893
+ /** Return all cross-product edges. */
894
+ getAllCrossEdges() {
895
+ return this.doc?.cross_edges ?? [];
896
+ }
897
+ /** Find a cross-product edge by ID. */
898
+ getCrossEdge(id) {
899
+ return this.doc?.cross_edges.find((e) => e.id === id);
900
+ }
901
+ // ── Migration: inline → portfolio ─────────────────────────
902
+ //
903
+ // Scans `sourceDoc` for edges whose type is in UPG_CROSS_EDGE_TYPES, converts
904
+ // them to UPGCrossEdge objects with qualified IDs, and either:
905
+ // - dry_run: true → reports what would change without writing anything
906
+ // - dry_run: false → writes them to this portfolio document AND removes them
907
+ // from sourceDoc.edges (sourceDoc must be saved separately)
908
+ //
909
+ // `sourceProductId` is the product ID that owns the sourceDoc. When the target
910
+ // node is NOT in sourceDoc, the caller must supply `targetProductId`; without
911
+ // it, those edges are skipped (reported in `skipped`).
912
+ /**
913
+ * Migrate inline cross-product edges from a product document into this
914
+ * portfolio document.
915
+ *
916
+ * **Does not flush** — caller is responsible for calling `.flush()` after
917
+ * inspecting the result and, for non-dry-run, also saving `sourceDoc`.
918
+ */
919
+ migrateCrossEdgesFromDoc(sourceDoc, sourceProductId, targetProductId, dryRun) {
920
+ if (!this.doc && !dryRun) {
921
+ throw new Error("Portfolio document not loaded. Call loadOrInit() first.");
922
+ }
923
+ const crossEdgeTypeSet = new Set(UPG_CROSS_EDGE_TYPES);
924
+ const sourceNodeIds = new Set(sourceDoc.nodes.map((n) => n.id));
925
+ const migrated = [];
926
+ const skipped = [];
927
+ const edgeIdsToRemove = [];
928
+ for (const edge of sourceDoc.edges) {
929
+ if (!crossEdgeTypeSet.has(edge.type)) continue;
930
+ const qualifiedSource = `${sourceProductId}/${edge.source}`;
931
+ let qualifiedTarget;
932
+ if (sourceNodeIds.has(edge.target)) {
933
+ qualifiedTarget = `${sourceProductId}/${edge.target}`;
934
+ } else if (targetProductId) {
935
+ qualifiedTarget = `${targetProductId}/${edge.target}`;
936
+ } else {
937
+ skipped.push({
938
+ id: edge.id,
939
+ reason: `Target node "${edge.target}" is not in the source product and no targetProductId was provided \u2014 cannot determine qualified target ID`
940
+ });
941
+ continue;
942
+ }
943
+ migrated.push({
944
+ id: edge.id,
945
+ source: qualifiedSource,
946
+ target: qualifiedTarget,
947
+ type: edge.type,
948
+ source_product_id: sourceProductId
949
+ });
950
+ edgeIdsToRemove.push(edge.id);
951
+ }
952
+ if (!dryRun && migrated.length > 0) {
953
+ if (!this.doc) throw new Error("Portfolio document not loaded.");
954
+ for (const m of migrated) {
955
+ const crossEdge = {
956
+ id: m.id,
957
+ source: m.source,
958
+ target: m.target,
959
+ type: m.type,
960
+ source_product_id: sourceProductId,
961
+ ...m.target.split("/")[0] !== sourceProductId ? { target_product_id: m.target.split("/")[0] } : {}
962
+ };
963
+ this.doc.cross_edges.push(crossEdge);
964
+ }
965
+ this.dirty = true;
966
+ const removeSet = new Set(edgeIdsToRemove);
967
+ sourceDoc.edges = sourceDoc.edges.filter((e) => !removeSet.has(e.id));
968
+ }
969
+ return { migrated, skipped, dry_run: dryRun };
970
+ }
971
+ };
972
+
973
+ // src/lib/tools.ts
974
+ function canonicalType(name) {
975
+ return getReplacementType(name) ?? name;
976
+ }
977
+ function validateStatusAgainstLifecycle(entityType, status) {
978
+ const lifecycle = getLifecycleForType(entityType);
979
+ if (!lifecycle) return void 0;
980
+ const validPhases = lifecycle.phases.map((p) => p.id);
981
+ if (!validPhases.includes(status)) {
982
+ return `Status "${status}" is not a valid phase for type "${entityType}". Valid phases: [${validPhases.join(", ")}]`;
983
+ }
984
+ return void 0;
985
+ }
986
+ function getDefaultStatus(entityType) {
987
+ const lifecycle = getLifecycleForType(entityType);
988
+ return lifecycle?.initial_phase;
989
+ }
990
+ function autoFillSlug(node, store) {
991
+ if (node.slug) return;
992
+ if (!node.title) return;
993
+ const existing = collectSlugsForType(store.getAllNodes(), node.type);
994
+ const base = generateSlug(node.title);
995
+ node.slug = resolveSlugCollision(base, existing);
996
+ }
997
+ function normalizeTags(tags) {
998
+ if (!tags) return void 0;
999
+ if (Array.isArray(tags)) return tags;
1000
+ if (typeof tags === "string") {
1001
+ try {
1002
+ const parsed = JSON.parse(tags);
1003
+ if (Array.isArray(parsed)) return parsed;
1004
+ } catch {
1005
+ }
1006
+ return [tags];
1007
+ }
1008
+ return void 0;
1009
+ }
1010
+ var BUSINESS_AREAS = {
1011
+ identity: { emoji: "\u{1F3AF}", types: ["product", "vision", "mission"] },
1012
+ understanding: { emoji: "\u{1F464}", types: ["persona", "job", "need", "research_study", "insight"] },
1013
+ // Surface canonical post-split types. `hypothesis` is canonical
1014
+ // (re-promoted in v0.4.0 from hypothesis_claim). `hypothesis_evidence`
1015
+ // is deprecated — use `evidence` + hypothesis_has_evidence edge instead.
1016
+ // `experiment` remains canonical alongside `experiment_plan` / `experiment_run`.
1017
+ discovery: { emoji: "\u{1F4A1}", types: ["opportunity", "solution", "competitor", "hypothesis", "experiment_plan", "experiment_run", "learning"] },
1018
+ // `validation` overlaps `discovery` semantically but tracks the stage's
1019
+ // characteristic artefacts (hypothesis tested, evidence captured, experiment
1020
+ // run-throughs). Surfaced as its own region so STAGE_COVERAGE_TARGETS can
1021
+ // gate on it at `validation` stage without re-graveling `discovery`.
1022
+ validation: { emoji: "\u{1F9EA}", types: ["hypothesis", "experiment_plan", "experiment_run", "evidence", "learning"] },
1023
+ reaching: { emoji: "\u{1F4E3}", types: ["ideal_customer_profile", "positioning", "messaging", "acquisition_channel", "content_strategy"] },
1024
+ converting: { emoji: "\u{1F4B0}", types: ["value_proposition", "pricing_tier", "funnel", "funnel_step"] },
1025
+ // (since v0.4.0) story_task collapsed into task; building area uses task for story work.
1026
+ building: { emoji: "\u{1F4E6}", types: ["feature", "story_statement", "epic", "release", "user_journey", "user_flow"] },
1027
+ sustaining: { emoji: "\u{1F3E6}", types: ["business_model", "revenue_stream", "cost_structure", "unit_economics", "pricing_strategy"] },
1028
+ learning: { emoji: "\u{1F4CA}", types: ["outcome", "metric", "objective", "key_result", "retrospective"] },
1029
+ // Operations is a maintenance-stage concern — incidents, postmortems, error
1030
+ // budgets only become coverage-relevant once a product is in `maintenance`.
1031
+ // Surfaced informationally at earlier stages.
1032
+ operations: { emoji: "\u{1F6A8}", types: ["incident", "postmortem", "error_budget"] }
1033
+ };
1034
+ var STAGE_COVERAGE_TARGETS = {
1035
+ concept: ["identity", "understanding", "discovery"],
1036
+ validation: ["identity", "understanding", "discovery", "validation"],
1037
+ build: ["identity", "understanding", "discovery", "validation", "building"],
1038
+ beta: ["identity", "understanding", "discovery", "validation", "building", "reaching", "converting"],
1039
+ launch: ["identity", "understanding", "discovery", "validation", "building", "reaching", "converting", "sustaining"],
1040
+ growth: ["identity", "understanding", "discovery", "validation", "building", "reaching", "converting", "sustaining", "learning"],
1041
+ mature: ["identity", "understanding", "discovery", "validation", "building", "reaching", "converting", "sustaining", "learning"],
1042
+ maintenance: ["identity", "understanding", "discovery", "validation", "building", "reaching", "converting", "sustaining", "learning", "operations"],
1043
+ // Sunset products are winding down — Identity stays (the product still has
1044
+ // a name + vision) and Learning becomes the priority (capture the
1045
+ // retrospective + reasons for sunsetting). Everything else is informational.
1046
+ sunset: ["identity", "learning"]
1047
+ };
1048
+ function resolveCoverageStage(rawStage) {
1049
+ if (typeof rawStage === "string") {
1050
+ const coerced = coerceProductStage(rawStage);
1051
+ if (coerced.canonical) return coerced.canonical;
1052
+ if (rawStage.toLowerCase() === "idea") return "concept";
1053
+ }
1054
+ return "concept";
1055
+ }
1056
+ var LIFECYCLE_PHASES = {
1057
+ strategy: ["product", "outcome", "metric", "objective", "key_result", "vision", "mission", "strategic_theme", "initiative"],
1058
+ users: ["persona", "job", "need", "desired_outcome", "job_step"],
1059
+ discovery: ["opportunity", "solution", "research_study", "insight", "competitor"],
1060
+ // (since v0.4.0) hypothesis is canonical; hypothesis_claim/hypothesis_evidence
1061
+ // are deprecated aliases. evidence replaces hypothesis_evidence in new graphs.
1062
+ validation: ["hypothesis", "experiment_plan", "experiment_run", "learning", "evidence", "experiment", "hypothesis_claim", "hypothesis_evidence"],
1063
+ // (since v0.4.0) story_task collapsed into task (deprecated alias).
1064
+ execution: ["feature", "epic", "story_statement", "release", "task", "bug", "user_story", "story_task"]
1065
+ };
1066
+ var CHAINS = [
1067
+ { name: "persona \u2192 job", from: "persona", to: "job", edgePattern: "job" },
1068
+ { name: "job \u2192 need", from: "job", to: "need", edgePattern: "need" },
1069
+ { name: "opportunity \u2192 solution", from: "opportunity", to: "solution", edgePattern: "solution" },
1070
+ { name: "solution \u2192 hypothesis", from: "solution", to: "hypothesis", edgePattern: "hypothesis" },
1071
+ { name: "hypothesis \u2192 experiment_plan", from: "hypothesis", to: "experiment_plan", edgePattern: "experiment_plan" },
1072
+ { name: "experiment_run \u2192 learning", from: "experiment_run", to: "learning", edgePattern: "learning" },
1073
+ { name: "objective \u2192 key_result", from: "objective", to: "key_result", edgePattern: "key_result" },
1074
+ // (v0.2.7 split 2) features specify story_statements (the design
1075
+ // artefact / promise). story_tasks are the delivery work, linked from
1076
+ // story_statement via story_task_implements_story_statement.
1077
+ { name: "feature \u2192 story_statement", from: "feature", to: "story_statement", edgePattern: "story_statement" }
1078
+ ];
1079
+ var TYPE_SORT_ORDER = [
1080
+ // Identity
1081
+ "product",
1082
+ "vision",
1083
+ "mission",
1084
+ // Users
1085
+ "persona",
1086
+ "job",
1087
+ "job_step",
1088
+ "need",
1089
+ "desired_outcome",
1090
+ // Discovery
1091
+ "outcome",
1092
+ "opportunity",
1093
+ "solution",
1094
+ "research_study",
1095
+ "insight",
1096
+ // Validation
1097
+ "hypothesis",
1098
+ "experiment",
1099
+ "learning",
1100
+ "evidence",
1101
+ // Competition
1102
+ "competitor",
1103
+ "competitor_feature",
1104
+ // Strategy
1105
+ "strategic_theme",
1106
+ "initiative",
1107
+ "objective",
1108
+ "key_result",
1109
+ "metric",
1110
+ // Reaching
1111
+ "ideal_customer_profile",
1112
+ "market_segment",
1113
+ "positioning",
1114
+ "messaging",
1115
+ "acquisition_channel",
1116
+ "content_strategy",
1117
+ // Converting
1118
+ "value_proposition",
1119
+ "pricing_tier",
1120
+ "pricing_strategy",
1121
+ "funnel",
1122
+ "funnel_step",
1123
+ // Building
1124
+ "feature",
1125
+ "feature_area",
1126
+ "epic",
1127
+ "user_story",
1128
+ "release",
1129
+ "user_journey",
1130
+ "user_flow",
1131
+ "screen",
1132
+ "screen_state",
1133
+ // Architecture
1134
+ "bounded_context",
1135
+ "service",
1136
+ "api_endpoint",
1137
+ "database_schema",
1138
+ "architecture_decision",
1139
+ // Sustaining
1140
+ "business_model",
1141
+ "revenue_stream",
1142
+ "cost_structure",
1143
+ "unit_economics",
1144
+ // Learning
1145
+ "retrospective"
1146
+ ];
1147
+ function typeSortPriority(type) {
1148
+ const idx = TYPE_SORT_ORDER.indexOf(type);
1149
+ return idx >= 0 ? idx : 999;
1150
+ }
1151
+ function sortByType(nodes) {
1152
+ return [...nodes].sort((a, b) => {
1153
+ const priorityDiff = typeSortPriority(a.type) - typeSortPriority(b.type);
1154
+ if (priorityDiff !== 0) return priorityDiff;
1155
+ return a.title.localeCompare(b.title);
1156
+ });
1157
+ }
1158
+ function computeGraphDigest(store) {
1159
+ const nodes = store.getAllNodes();
1160
+ const edges = store.getAllEdges();
1161
+ const product = store.getProduct();
1162
+ const byType = {};
1163
+ for (const n of nodes) byType[n.type] = (byType[n.type] ?? 0) + 1;
1164
+ const byCanonicalType = {};
1165
+ for (const n of nodes) {
1166
+ const c = canonicalType(n.type);
1167
+ byCanonicalType[c] = (byCanonicalType[c] ?? 0) + 1;
1168
+ }
1169
+ const connectedNodes = /* @__PURE__ */ new Set();
1170
+ for (const e of edges) {
1171
+ connectedNodes.add(e.source);
1172
+ connectedNodes.add(e.target);
1173
+ }
1174
+ const orphanCount = nodes.filter((n) => !connectedNodes.has(n.id)).length;
1175
+ const hypothesisCount = byCanonicalType["hypothesis"] ?? 0;
1176
+ const experimentCount = (byCanonicalType["experiment_plan"] ?? 0) + (byCanonicalType["experiment_run"] ?? 0) + // `experiment` is still a canonical type for back-compat reads; count it too.
1177
+ (byCanonicalType["experiment"] ?? 0);
1178
+ const personaCount = byCanonicalType["persona"] ?? 0;
1179
+ const chainStats = (parentType, edgePattern) => {
1180
+ let withChild = 0;
1181
+ const parents = nodes.filter((n) => canonicalType(n.type) === parentType);
1182
+ for (const p of parents) {
1183
+ const pEdges = store.getEdgesForNode(p.id);
1184
+ const matches = pEdges.some(
1185
+ (e) => e.source === p.id && (e.type.includes(edgePattern) || e.type.includes(canonicalType(edgePattern)) || // Match deprecated edge fragments by resolving each segment to canonical.
1186
+ e.type.split("_").some((seg) => canonicalType(seg) === edgePattern))
1187
+ );
1188
+ if (matches) withChild++;
1189
+ }
1190
+ return { with_child: withChild, total: parents.length };
1191
+ };
1192
+ const chainStatsWithBridge = (parentType, edgePattern, bridges) => {
1193
+ let withChild = 0;
1194
+ const parents = nodes.filter((n) => canonicalType(n.type) === parentType);
1195
+ for (const p of parents) {
1196
+ const pEdges = store.getEdgesForNode(p.id);
1197
+ const directMatch = pEdges.some(
1198
+ (e) => e.source === p.id && (e.type.includes(edgePattern) || e.type.includes(canonicalType(edgePattern)) || e.type.split("_").some((seg) => canonicalType(seg) === edgePattern))
1199
+ );
1200
+ if (directMatch) {
1201
+ withChild++;
1202
+ continue;
1203
+ }
1204
+ let bridgeMatch = false;
1205
+ for (const bridge of bridges) {
1206
+ const incoming = pEdges.filter(
1207
+ (e) => e.target === p.id && e.type.includes(bridge.incoming_edge_substring)
1208
+ );
1209
+ for (const inE of incoming) {
1210
+ const bridgeNode = store.getNode(inE.source);
1211
+ if (!bridgeNode || canonicalType(bridgeNode.type) !== bridge.incoming_from) continue;
1212
+ const bridgeOut = store.getEdgesForNode(bridgeNode.id);
1213
+ const reachesChild = bridgeOut.some((be) => {
1214
+ if (be.source !== bridgeNode.id) return false;
1215
+ if (!be.type.includes(bridge.bridge_outgoing_edge_substring)) return false;
1216
+ const childNode = store.getNode(be.target);
1217
+ return childNode != null && canonicalType(childNode.type) === bridge.bridge_to_type;
1218
+ });
1219
+ if (reachesChild) {
1220
+ bridgeMatch = true;
1221
+ break;
1222
+ }
1223
+ }
1224
+ if (bridgeMatch) break;
1225
+ }
1226
+ if (bridgeMatch) withChild++;
1227
+ }
1228
+ return { with_child: withChild, total: parents.length };
1229
+ };
1230
+ const personaJob = chainStats("persona", "job");
1231
+ const jobNeed = chainStatsWithBridge(
1232
+ "job",
1233
+ "need",
1234
+ [
1235
+ {
1236
+ incoming_from: "persona",
1237
+ incoming_edge_substring: "persona_pursues_job",
1238
+ bridge_outgoing_edge_substring: "persona_experiences_need",
1239
+ bridge_to_type: "need"
1240
+ }
1241
+ ]
1242
+ );
1243
+ const oppSolution = chainStats("opportunity", "solution");
1244
+ const hypExperiment = chainStats("hypothesis", "experiment_plan");
1245
+ const expLearning = chainStats("experiment_run", "learning");
1246
+ const typeSet = /* @__PURE__ */ new Set();
1247
+ for (const t of Object.keys(byType)) {
1248
+ typeSet.add(t);
1249
+ typeSet.add(canonicalType(t));
1250
+ }
1251
+ const rawStage = product.stage ?? nodes.find((n) => n.type === "product")?.properties?.stage;
1252
+ const resolvedStage = resolveCoverageStage(rawStage);
1253
+ const countedRegions = new Set(STAGE_COVERAGE_TARGETS[resolvedStage] ?? []);
1254
+ const coverage = {};
1255
+ const countedRegionStats = [];
1256
+ for (const [area, def] of Object.entries(BUSINESS_AREAS)) {
1257
+ const present = def.types.filter((t) => typeSet.has(t));
1258
+ const missing = def.types.filter((t) => !typeSet.has(t));
1259
+ const isCounted = countedRegions.has(area);
1260
+ const region = {
1261
+ covered: present.length,
1262
+ total: def.types.length,
1263
+ counted_toward_stage: isCounted,
1264
+ types_present: present,
1265
+ types_missing: missing
1266
+ };
1267
+ coverage[area] = region;
1268
+ if (isCounted) countedRegionStats.push({ covered: region.covered, total: region.total });
1269
+ }
1270
+ const regionsComplete = countedRegionStats.filter((s) => s.total > 0 && s.covered === s.total).length;
1271
+ const regionsPartial = countedRegionStats.filter((s) => s.covered > 0 && s.covered < s.total).length;
1272
+ const perRegionPcts = countedRegionStats.map((s) => s.total === 0 ? 100 : s.covered / s.total * 100);
1273
+ const overallPct = perRegionPcts.length === 0 ? 0 : Math.round(perRegionPcts.reduce((sum, pct) => sum + pct, 0) / perRegionPcts.length);
1274
+ coverage.stage_summary = {
1275
+ stage: resolvedStage,
1276
+ regions_counted: countedRegionStats.length,
1277
+ regions_complete: regionsComplete,
1278
+ regions_partial: regionsPartial,
1279
+ overall_pct: overallPct
1280
+ };
1281
+ const lifecycle = {};
1282
+ for (const [phase, types] of Object.entries(LIFECYCLE_PHASES)) {
1283
+ const canonicalSet = new Set(types.map((t) => canonicalType(t)));
1284
+ lifecycle[phase] = [...canonicalSet].reduce(
1285
+ (sum, t) => sum + (byCanonicalType[t] ?? 0),
1286
+ 0
1287
+ );
1288
+ }
1289
+ return {
1290
+ product: {
1291
+ title: product.title,
1292
+ stage: product.stage ?? nodes.find((n) => n.type === "product")?.properties?.stage ?? "unknown"
1293
+ },
1294
+ counts: { total_nodes: nodes.length, total_edges: edges.length, by_type: byType },
1295
+ health: {
1296
+ orphan_count: orphanCount,
1297
+ orphan_rate: nodes.length > 0 ? Math.round(orphanCount / nodes.length * 100) / 100 : 0,
1298
+ connectivity: nodes.length > 0 ? Math.round((nodes.length - orphanCount) / nodes.length * 100) / 100 : 0,
1299
+ validation_rate: hypothesisCount > 0 ? Math.round(experimentCount / hypothesisCount * 100) / 100 : 0,
1300
+ user_coverage: personaCount > 0 ? Math.round(personaJob.with_child / personaCount * 100) / 100 : 0
1301
+ },
1302
+ chains: {
1303
+ persona_with_job: personaJob.with_child,
1304
+ persona_total: personaJob.total,
1305
+ job_with_need: jobNeed.with_child,
1306
+ job_total: jobNeed.total,
1307
+ opportunity_with_solution: oppSolution.with_child,
1308
+ opportunity_total: oppSolution.total,
1309
+ hypothesis_untested: hypothesisCount - hypExperiment.with_child,
1310
+ hypothesis_total: hypothesisCount,
1311
+ experiment_with_learning: expLearning.with_child,
1312
+ experiment_total: experimentCount
1313
+ },
1314
+ coverage,
1315
+ lifecycle
1316
+ };
1317
+ }
1318
+ function searchNodes(store, query, options) {
1319
+ const q = query.toLowerCase();
1320
+ const searchFields = new Set(options?.fields ?? ["title", "description"]);
1321
+ const limit = Math.min(options?.limit ?? 20, 100);
1322
+ let nodes = store.getAllNodes();
1323
+ if (options?.type) nodes = nodes.filter((n) => n.type === options.type);
1324
+ return nodes.map((n) => {
1325
+ let bestScore = 0;
1326
+ let matchField = "";
1327
+ if (searchFields.has("title") && n.title.toLowerCase().includes(q)) {
1328
+ bestScore = 3;
1329
+ matchField = "title";
1330
+ }
1331
+ if (searchFields.has("tags") && normalizeTags(n.tags)?.some((t) => t.toLowerCase().includes(q))) {
1332
+ if (2 > bestScore) {
1333
+ bestScore = 2;
1334
+ matchField = "tags";
1335
+ }
1336
+ }
1337
+ if (searchFields.has("description") && n.description?.toLowerCase().includes(q)) {
1338
+ if (1 > bestScore) {
1339
+ bestScore = 1;
1340
+ matchField = "description";
1341
+ }
1342
+ }
1343
+ if (searchFields.has("properties") && n.properties) {
1344
+ const propsStr = JSON.stringify(n.properties).toLowerCase();
1345
+ if (propsStr.includes(q)) {
1346
+ if (1 > bestScore) {
1347
+ bestScore = 1;
1348
+ matchField = "properties";
1349
+ }
1350
+ }
1351
+ }
1352
+ if (bestScore === 0) return null;
1353
+ return { node: n, score: bestScore, match_field: matchField };
1354
+ }).filter((s) => s !== null).sort((a, b) => b.score - a.score).slice(0, limit);
1355
+ }
1356
+ function computeHealthScore(digest) {
1357
+ const orphanRate = digest.health.orphan_rate;
1358
+ const orphanScore = Math.max(0, 100 - orphanRate * 200);
1359
+ const regions = Object.entries(digest.coverage).filter(([key]) => key !== "stage_summary").map(([, value]) => value);
1360
+ const domainsCovered = regions.filter((c) => c.covered > 0).length;
1361
+ const domainScore = domainsCovered / Object.keys(BUSINESS_AREAS).length * 100;
1362
+ let chainsComplete = 0;
1363
+ const chainPairs = [
1364
+ [digest.chains.persona_with_job, digest.chains.persona_total],
1365
+ [digest.chains.job_with_need, digest.chains.job_total],
1366
+ [digest.chains.opportunity_with_solution, digest.chains.opportunity_total],
1367
+ [digest.chains.experiment_with_learning, digest.chains.experiment_total]
1368
+ ];
1369
+ for (const [connected, total] of chainPairs) {
1370
+ if (total > 0 && connected === total) chainsComplete++;
1371
+ }
1372
+ const chainScore = chainPairs.length > 0 ? chainsComplete / chainPairs.length * 100 : 100;
1373
+ const validationScore = digest.health.validation_rate * 100;
1374
+ return Math.round(
1375
+ orphanScore * 0.25 + domainScore * 0.25 + chainScore * 0.3 + validationScore * 0.2
1376
+ );
1377
+ }
1378
+ function getOrphans(store) {
1379
+ const connectedNodes = /* @__PURE__ */ new Set();
1380
+ for (const e of store.getAllEdges()) {
1381
+ connectedNodes.add(e.source);
1382
+ connectedNodes.add(e.target);
1383
+ }
1384
+ return store.getAllNodes().filter((n) => !connectedNodes.has(n.id));
1385
+ }
1386
+ function listNodes(store, options) {
1387
+ let nodes = store.getAllNodes();
1388
+ if (options?.type) nodes = nodes.filter((n) => n.type === options.type);
1389
+ if (options?.status) nodes = nodes.filter((n) => n.status === options.status);
1390
+ if (options?.tags && options.tags.length > 0) {
1391
+ const filterTags = options.tags;
1392
+ nodes = nodes.filter((n) => normalizeTags(n.tags)?.some((t) => filterTags.includes(t)));
1393
+ }
1394
+ if (options?.parentId) {
1395
+ const parentEdges = store.getEdgesForNode(options.parentId);
1396
+ const childIds = new Set(
1397
+ parentEdges.filter((e) => e.source === options.parentId).map((e) => e.target)
1398
+ );
1399
+ nodes = nodes.filter((n) => childIds.has(n.id));
1400
+ }
1401
+ const total = nodes.length;
1402
+ const offset = options?.offset ?? 0;
1403
+ const limit = Math.min(options?.limit ?? 50, 200);
1404
+ const page = nodes.slice(offset, offset + limit).map((n) => {
1405
+ const entry = {
1406
+ id: n.id,
1407
+ type: n.type,
1408
+ title: n.title,
1409
+ status: n.status,
1410
+ tags: n.tags
1411
+ };
1412
+ if (options?.includeEdges) {
1413
+ entry.edges = store.getEdgesForNode(n.id).map((e) => ({
1414
+ id: e.id,
1415
+ type: e.type,
1416
+ source: e.source,
1417
+ target: e.target
1418
+ }));
1419
+ }
1420
+ return entry;
1421
+ });
1422
+ return { nodes: page, total };
1423
+ }
1424
+ function getNode(store, args) {
1425
+ const node = store.getNode(args.node_id);
1426
+ if (!node) return null;
1427
+ const compact = args.compact_edges ?? false;
1428
+ const edges = store.getEdgesForNode(args.node_id);
1429
+ const edgesOut = edges.filter((e) => e.source === args.node_id).map(
1430
+ (e) => compact ? { id: e.id, type: e.type, source: e.source, target: e.target } : { ...e, target_title: store.getNode(e.target)?.title ?? "(unknown)" }
1431
+ );
1432
+ const edgesIn = edges.filter((e) => e.target === args.node_id).map(
1433
+ (e) => compact ? { id: e.id, type: e.type, source: e.source, target: e.target } : { ...e, source_title: store.getNode(e.source)?.title ?? "(unknown)" }
1434
+ );
1435
+ return { node, edges_out: edgesOut, edges_in: edgesIn };
1436
+ }
1437
+ function getNodes(store, args) {
1438
+ const compact = args.compact_edges ?? false;
1439
+ const results = [];
1440
+ const notFound = [];
1441
+ for (const id of args.ids) {
1442
+ const result = getNode(store, { node_id: id, compact_edges: compact });
1443
+ if (!result) {
1444
+ notFound.push(id);
1445
+ continue;
1446
+ }
1447
+ results.push(result);
1448
+ }
1449
+ const response = { nodes: results, total: results.length };
1450
+ if (notFound.length > 0) response.not_found = notFound;
1451
+ return response;
1452
+ }
1453
+ function createNode(store, args) {
1454
+ const resolved = resolveEntityType(args.type);
1455
+ const canonicalNodeType = resolved.canonical;
1456
+ const aliasWarning = resolved.alias ? `Type '${resolved.alias.from}' aliased to canonical '${resolved.alias.to}'. Update your caller to use '${resolved.alias.to}' directly.` : void 0;
1457
+ const newNode = {
1458
+ id: nodeId(),
1459
+ type: canonicalNodeType,
1460
+ title: args.title
1461
+ };
1462
+ if (args.description) newNode.description = args.description;
1463
+ if (args.tags) newNode.tags = normalizeTags(args.tags) ?? [];
1464
+ if (args.properties) newNode.properties = args.properties;
1465
+ autoFillSlug(newNode, store);
1466
+ let warning = aliasWarning;
1467
+ if (args.status) {
1468
+ newNode.status = args.status;
1469
+ const statusWarning = validateStatusAgainstLifecycle(canonicalNodeType, args.status);
1470
+ if (statusWarning) warning = warning ? `${warning} | ${statusWarning}` : statusWarning;
1471
+ } else {
1472
+ const defaultStatus = getDefaultStatus(canonicalNodeType);
1473
+ if (defaultStatus) newNode.status = defaultStatus;
1474
+ }
1475
+ store.addNode(newNode);
1476
+ let edge = null;
1477
+ if (args.parent_id) {
1478
+ const parent = store.getNode(args.parent_id);
1479
+ if (!parent) {
1480
+ return {
1481
+ node: newNode,
1482
+ edge: null,
1483
+ warning: (warning ? warning + " | " : "") + `Parent node ${args.parent_id} not found. Node created without edge.`
1484
+ };
1485
+ }
1486
+ const inference = inferEdgeTypeWithTier(parent.type, canonicalNodeType);
1487
+ if (!inference.ok) {
1488
+ const suggestion = inference.suggestions.length > 0 ? ` Suggestions: ${inference.suggestions.map((s) => `${s.source_type} \u2192 ${s.target_type} (${s.edge_type})`).join("; ")}.` : "";
1489
+ return {
1490
+ node: newNode,
1491
+ edge: null,
1492
+ warning: (warning ? warning + " | " : "") + `Parent edge not created \u2014 no canonical edge for ${parent.type} \u2192 ${canonicalNodeType}.${suggestion}`
1493
+ };
1494
+ }
1495
+ edge = {
1496
+ id: edgeId(),
1497
+ source: args.parent_id,
1498
+ target: newNode.id,
1499
+ type: inference.edgeType
1500
+ };
1501
+ store.addEdge(edge);
1502
+ }
1503
+ return warning ? { node: newNode, edge, warning } : { node: newNode, edge };
1504
+ }
1505
+ function createEdge(store, args) {
1506
+ let targetId = args.target_id;
1507
+ if (!targetId && !args.target_title) {
1508
+ return { error: "Provide either target_id or target_title (with target_type)" };
1509
+ }
1510
+ if (!targetId && args.target_title) {
1511
+ if (!args.target_type) {
1512
+ return { error: "target_type is required when using target_title" };
1513
+ }
1514
+ const candidates = store.getAllNodes().filter(
1515
+ (n) => n.type === args.target_type && n.title.toLowerCase() === args.target_title.toLowerCase()
1516
+ );
1517
+ if (candidates.length === 0) {
1518
+ return { error: `No ${args.target_type} found with title "${args.target_title}"` };
1519
+ }
1520
+ if (candidates.length > 1) {
1521
+ return {
1522
+ error: `Ambiguous: ${candidates.length} nodes match "${args.target_title}" (type: ${args.target_type}). Use target_id instead. IDs: ${candidates.map((c) => c.id).join(", ")}`
1523
+ };
1524
+ }
1525
+ targetId = candidates[0].id;
1526
+ }
1527
+ const source = store.getNode(args.source_id);
1528
+ const target = store.getNode(targetId);
1529
+ if (!source) return { error: `Source not found: ${args.source_id}` };
1530
+ if (!target) return { error: `Target not found: ${targetId}` };
1531
+ if (args.source_id === targetId) {
1532
+ return {
1533
+ error: `Self-loop refused: source and target resolve to the same node "${args.source_id}". No canonical UPG edge type is self-referential. If you genuinely need a self-referential edge, file a spec proposal first.`
1534
+ };
1535
+ }
1536
+ let edgeType;
1537
+ let edgeWarning;
1538
+ if (args.type) {
1539
+ const pairCheck = validateEdgeTypePair(args.type, source.type, target.type);
1540
+ if (!pairCheck.valid) {
1541
+ return { error: pairCheck.reason };
1542
+ }
1543
+ edgeType = args.type;
1544
+ } else {
1545
+ const inference = inferEdgeTypeWithTier(source.type, target.type);
1546
+ if (!inference.ok) {
1547
+ const suggestion = inference.suggestions.length > 0 ? ` Try one of: ${inference.suggestions.map((s) => `${s.source_type} \u2192 ${s.target_type} (${s.edge_type})`).join("; ")}.` : "";
1548
+ return {
1549
+ error: `No canonical edge type for ${source.type} \u2192 ${target.type}.${suggestion} Pass an explicit \`type\` if you need a non-catalog edge.`,
1550
+ no_canonical_edge_for: {
1551
+ source_type: source.type,
1552
+ target_type: target.type
1553
+ }
1554
+ };
1555
+ }
1556
+ edgeType = inference.edgeType;
1557
+ if (inference.aliased) {
1558
+ const parts = inference.aliased.map((a) => `${a.from} \u2192 ${a.to}`).join(", ");
1559
+ edgeWarning = `Edge inferred from canonical (${parts}).`;
1560
+ }
1561
+ }
1562
+ const edge = {
1563
+ id: edgeId(),
1564
+ source: args.source_id,
1565
+ target: targetId,
1566
+ type: edgeType
1567
+ };
1568
+ store.addEdge(edge);
1569
+ const response = { edge };
1570
+ if (edgeWarning) response.warning = edgeWarning;
1571
+ return response;
1572
+ }
1573
+ function deleteNode(store, args) {
1574
+ const { node, removedEdgeIds } = store.removeNode(args.node_id);
1575
+ return {
1576
+ deleted_node_id: node.id,
1577
+ deleted_node_title: node.title,
1578
+ deleted_edge_ids: removedEdgeIds
1579
+ };
1580
+ }
1581
+ function deleteEdge(store, args) {
1582
+ const edge = store.removeEdge(args.edge_id);
1583
+ return { deleted_edge_id: edge.id };
1584
+ }
1585
+ function findParentEdges(store, nodeId2) {
1586
+ const incoming = store.getEdgesForNode(nodeId2).filter((e) => e.target === nodeId2);
1587
+ return incoming.filter((e) => {
1588
+ const def = UPG_EDGE_CATALOG[e.type];
1589
+ return def?.classification === "hierarchy";
1590
+ });
1591
+ }
1592
+ function moveNode(store, args) {
1593
+ const node = store.getNode(args.node_id);
1594
+ if (!node) return { moved: false, error: `Node not found: ${args.node_id}` };
1595
+ const newParent = store.getNode(args.new_parent_id);
1596
+ if (!newParent) {
1597
+ return { moved: false, error: `New parent not found: ${args.new_parent_id}` };
1598
+ }
1599
+ if (args.node_id === args.new_parent_id) {
1600
+ return { moved: false, error: "Cannot move a node onto itself." };
1601
+ }
1602
+ let oldEdge = null;
1603
+ if (args.old_edge_id) {
1604
+ const explicit = store.getEdge(args.old_edge_id);
1605
+ if (!explicit) {
1606
+ return { moved: false, error: `old_edge_id not found: ${args.old_edge_id}` };
1607
+ }
1608
+ if (explicit.target !== args.node_id) {
1609
+ return {
1610
+ moved: false,
1611
+ error: `old_edge_id ${args.old_edge_id} does not target node ${args.node_id}.`
1612
+ };
1613
+ }
1614
+ oldEdge = explicit;
1615
+ } else {
1616
+ const parents = findParentEdges(store, args.node_id);
1617
+ if (parents.length > 1) {
1618
+ return {
1619
+ moved: false,
1620
+ error: `Node has ${parents.length} hierarchy edges; pass old_edge_id to disambiguate. Candidates: ${parents.map((e) => `${e.id} (${e.type})`).join(", ")}`
1621
+ };
1622
+ }
1623
+ oldEdge = parents[0] ?? null;
1624
+ }
1625
+ let newEdgeType;
1626
+ let aliasWarning;
1627
+ if (args.new_edge_type) {
1628
+ if (!UPG_EDGE_CATALOG[args.new_edge_type]) {
1629
+ return {
1630
+ moved: false,
1631
+ error: `new_edge_type "${args.new_edge_type}" is not in UPG_EDGE_CATALOG.`
1632
+ };
1633
+ }
1634
+ newEdgeType = args.new_edge_type;
1635
+ } else {
1636
+ const inference = inferEdgeTypeWithTier(newParent.type, node.type);
1637
+ if (!inference.ok) {
1638
+ const suggestion = inference.suggestions.length > 0 ? ` Suggestions: ${inference.suggestions.map((s) => `${s.source_type} \u2192 ${s.target_type} (${s.edge_type})`).join("; ")}.` : "";
1639
+ return {
1640
+ moved: false,
1641
+ error: `No canonical edge for ${newParent.type} \u2192 ${node.type}.${suggestion} Pass an explicit new_edge_type.`
1642
+ };
1643
+ }
1644
+ newEdgeType = inference.edgeType;
1645
+ if (inference.aliased) {
1646
+ const parts = inference.aliased.map((a) => `${a.from} \u2192 ${a.to}`).join(", ");
1647
+ aliasWarning = `Edge inferred from canonical (${parts}).`;
1648
+ }
1649
+ }
1650
+ const def = UPG_EDGE_CATALOG[newEdgeType];
1651
+ if (def.source_type !== newParent.type) {
1652
+ return {
1653
+ moved: false,
1654
+ error: `Edge "${newEdgeType}" requires source type "${def.source_type}", got "${newParent.type}".`
1655
+ };
1656
+ }
1657
+ if (def.target_type !== node.type) {
1658
+ return {
1659
+ moved: false,
1660
+ error: `Edge "${newEdgeType}" requires target type "${def.target_type}", got "${node.type}".`
1661
+ };
1662
+ }
1663
+ const newEdge = {
1664
+ id: edgeId(),
1665
+ source: args.new_parent_id,
1666
+ target: args.node_id,
1667
+ type: newEdgeType
1668
+ };
1669
+ if (oldEdge) {
1670
+ store.removeEdge(oldEdge.id);
1671
+ }
1672
+ try {
1673
+ store.addEdge(newEdge);
1674
+ } catch (err) {
1675
+ if (oldEdge) {
1676
+ store.addEdge(oldEdge, true);
1677
+ }
1678
+ return {
1679
+ moved: false,
1680
+ error: `Failed to add new edge: ${err.message}. Graph rolled back.`
1681
+ };
1682
+ }
1683
+ return {
1684
+ moved: true,
1685
+ node_id: args.node_id,
1686
+ new_edge: newEdge,
1687
+ removed_edge_id: oldEdge?.id ?? null,
1688
+ ...oldEdge ? { removed_edge: oldEdge } : {},
1689
+ ...aliasWarning ? { warning: aliasWarning } : {}
1690
+ };
1691
+ }
1692
+ function batchMoveNodes(store, moves) {
1693
+ if (moves.length === 0) return { ok: false, error: "moves array is empty", failed_at_index: null };
1694
+ if (moves.length > 50) return { ok: false, error: "Maximum 50 moves per batch", failed_at_index: null };
1695
+ for (let i = 0; i < moves.length; i++) {
1696
+ const m = moves[i];
1697
+ const node = store.getNode(m.node_id);
1698
+ if (!node) return { ok: false, error: `Move at index ${i}: node not found: ${m.node_id}`, failed_at_index: i };
1699
+ const parent = store.getNode(m.new_parent_id);
1700
+ if (!parent) return { ok: false, error: `Move at index ${i}: new parent not found: ${m.new_parent_id}`, failed_at_index: i };
1701
+ if (m.node_id === m.new_parent_id) {
1702
+ return { ok: false, error: `Move at index ${i}: cannot move a node onto itself.`, failed_at_index: i };
1703
+ }
1704
+ if (m.new_edge_type && !UPG_EDGE_CATALOG[m.new_edge_type]) {
1705
+ return { ok: false, error: `Move at index ${i}: new_edge_type "${m.new_edge_type}" is not in UPG_EDGE_CATALOG.`, failed_at_index: i };
1706
+ }
1707
+ if (!m.new_edge_type) {
1708
+ const inference = inferEdgeTypeWithTier(parent.type, node.type);
1709
+ if (!inference.ok) {
1710
+ return { ok: false, error: `Move at index ${i}: no canonical edge for ${parent.type} \u2192 ${node.type}. Pass an explicit new_edge_type.`, failed_at_index: i };
1711
+ }
1712
+ }
1713
+ }
1714
+ const applied = [];
1715
+ for (let i = 0; i < moves.length; i++) {
1716
+ const result = moveNode(store, moves[i]);
1717
+ if (!result.moved) {
1718
+ for (let j = applied.length - 1; j >= 0; j--) {
1719
+ const a = applied[j];
1720
+ try {
1721
+ store.removeEdge(a.newEdge.id);
1722
+ } catch {
1723
+ }
1724
+ if (a.oldEdge) {
1725
+ try {
1726
+ store.addEdge(a.oldEdge, true);
1727
+ } catch {
1728
+ }
1729
+ }
1730
+ }
1731
+ return { ok: false, error: `Move at index ${i}: ${result.error}`, failed_at_index: i };
1732
+ }
1733
+ applied.push({
1734
+ newEdge: result.new_edge,
1735
+ oldEdge: result.removed_edge ?? null
1736
+ });
1737
+ }
1738
+ return {
1739
+ ok: true,
1740
+ result: {
1741
+ moves: applied.map((a, i) => ({
1742
+ node_id: moves[i].node_id,
1743
+ new_edge: a.newEdge,
1744
+ removed_edge_id: a.oldEdge?.id ?? null
1745
+ })),
1746
+ count: moves.length
1747
+ }
1748
+ };
1749
+ }
1750
+ function batchCreateNodes(store, args) {
1751
+ const { nodes, edges: explicitEdges = [] } = args;
1752
+ if (!Array.isArray(nodes)) return { ok: false, error: "Missing required parameter: nodes (array)" };
1753
+ if (nodes.length === 0) return { ok: false, error: "nodes array is empty" };
1754
+ if (nodes.length > 50) return { ok: false, error: "Maximum 50 nodes per batch" };
1755
+ if (nodes.length + explicitEdges.length > 50) {
1756
+ return { ok: false, error: `Maximum 50 items per batch (got ${nodes.length} nodes + ${explicitEdges.length} edges)` };
1757
+ }
1758
+ const resolvedTypes = [];
1759
+ const aliasWarnings = [];
1760
+ for (let i = 0; i < nodes.length; i++) {
1761
+ const n = nodes[i];
1762
+ if (!n.type) return { ok: false, error: `Node at index ${i}: missing required field "type"` };
1763
+ if (!n.title) return { ok: false, error: `Node at index ${i}: missing required field "title"` };
1764
+ try {
1765
+ const resolved = resolveEntityType(n.type);
1766
+ resolvedTypes.push(resolved.canonical);
1767
+ if (resolved.alias) {
1768
+ aliasWarnings.push(
1769
+ `Node at index ${i}: type '${resolved.alias.from}' aliased to canonical '${resolved.alias.to}'.`
1770
+ );
1771
+ }
1772
+ } catch (err) {
1773
+ if (err instanceof UnknownEntityTypeError) {
1774
+ return { ok: false, error: `Node at index ${i}: ${err.message}` };
1775
+ }
1776
+ throw err;
1777
+ }
1778
+ if (n.parent_ref !== void 0) {
1779
+ const match = n.parent_ref.match(/^\$(\d+)$/);
1780
+ if (!match) return { ok: false, error: `Node at index ${i}: invalid parent_ref "${n.parent_ref}" \u2014 use "$0", "$1", etc.` };
1781
+ const refIndex = parseInt(match[1], 10);
1782
+ if (refIndex >= i) return { ok: false, error: `Node at index ${i}: parent_ref "${n.parent_ref}" must reference an earlier index (0\u2013${i - 1})` };
1783
+ }
1784
+ if (n.parent_id !== void 0 && !store.getNode(n.parent_id)) {
1785
+ return { ok: false, error: `Node at index ${i}: parent_id "${n.parent_id}" not found in graph` };
1786
+ }
1787
+ }
1788
+ const validatedEdges = [];
1789
+ const resolveEdgeRef = (raw, label, edgeIndex) => {
1790
+ if (typeof raw !== "string" || raw.length === 0) {
1791
+ return { error: `Edge at index ${edgeIndex}: missing or invalid "${label}"` };
1792
+ }
1793
+ const refMatch = raw.match(/^\$(\d+)$/);
1794
+ if (refMatch) {
1795
+ const idx = parseInt(refMatch[1], 10);
1796
+ if (idx >= nodes.length) {
1797
+ return { error: `Edge at index ${edgeIndex}: ${label} "${raw}" out of range \u2014 only ${nodes.length} nodes in this batch.` };
1798
+ }
1799
+ return { kind: "ref", index: idx };
1800
+ }
1801
+ if (!store.getNode(raw)) {
1802
+ return { error: `Edge at index ${edgeIndex}: ${label} "${raw}" not found in graph (and is not a $N ref into this batch).` };
1803
+ }
1804
+ return { kind: "id", id: raw };
1805
+ };
1806
+ const refSourceType = (ref) => ref.kind === "ref" ? resolvedTypes[ref.index] : store.getNode(ref.id).type;
1807
+ for (let i = 0; i < explicitEdges.length; i++) {
1808
+ const e = explicitEdges[i];
1809
+ const fromResolved = resolveEdgeRef(e.from_ref, "from_ref", i);
1810
+ if ("error" in fromResolved) return { ok: false, error: fromResolved.error };
1811
+ const toResolved = resolveEdgeRef(e.to_ref, "to_ref", i);
1812
+ if ("error" in toResolved) return { ok: false, error: toResolved.error };
1813
+ const sameRef = fromResolved.kind === "ref" && toResolved.kind === "ref" && fromResolved.index === toResolved.index;
1814
+ const sameId = fromResolved.kind === "id" && toResolved.kind === "id" && fromResolved.id === toResolved.id;
1815
+ if (sameRef || sameId) {
1816
+ return {
1817
+ ok: false,
1818
+ error: `Edge at index ${i}: self-loop refused \u2014 source and target resolve to the same node. No canonical UPG edge type is self-referential.`
1819
+ };
1820
+ }
1821
+ let typeOverride;
1822
+ if (e.type !== void 0) {
1823
+ if (!UPG_EDGE_CATALOG[e.type]) {
1824
+ return { ok: false, error: `Edge at index ${i}: type "${e.type}" not in UPG_EDGE_CATALOG.` };
1825
+ }
1826
+ const sourceType = refSourceType(fromResolved);
1827
+ const targetType = refSourceType(toResolved);
1828
+ const pairCheck = validateEdgeTypePair(e.type, sourceType, targetType);
1829
+ if (!pairCheck.valid) {
1830
+ return { ok: false, error: `Edge at index ${i}: ${pairCheck.reason}` };
1831
+ }
1832
+ typeOverride = e.type;
1833
+ } else {
1834
+ const sourceType = refSourceType(fromResolved);
1835
+ const targetType = refSourceType(toResolved);
1836
+ const inference = inferEdgeTypeWithTier(sourceType, targetType);
1837
+ if (!inference.ok) {
1838
+ const suggestion = inference.suggestions.length > 0 ? ` Suggestions: ${inference.suggestions.map((s) => `${s.source_type} \u2192 ${s.target_type} (${s.edge_type})`).join("; ")}.` : "";
1839
+ return { ok: false, error: `Edge at index ${i}: no canonical edge for ${sourceType} \u2192 ${targetType}.${suggestion} Pass an explicit \`type\` to override.` };
1840
+ }
1841
+ }
1842
+ validatedEdges.push({ from: fromResolved, to: toResolved, typeOverride });
1843
+ }
1844
+ const createdNodes = [];
1845
+ const createdNodeRefs = [];
1846
+ const createdParentEdges = [];
1847
+ const explicitCreated = [];
1848
+ const warnings = [...aliasWarnings];
1849
+ const rollbackAll = () => {
1850
+ for (const e of explicitCreated.slice().reverse()) {
1851
+ try {
1852
+ store.removeEdge(e.id);
1853
+ } catch {
1854
+ }
1855
+ }
1856
+ for (const e of createdParentEdges.slice().reverse()) {
1857
+ try {
1858
+ store.removeEdge(e.id);
1859
+ } catch {
1860
+ }
1861
+ }
1862
+ for (const n of createdNodeRefs.slice().reverse()) {
1863
+ try {
1864
+ store.removeNode(n.id);
1865
+ } catch {
1866
+ }
1867
+ }
1868
+ };
1869
+ try {
1870
+ for (let i = 0; i < nodes.length; i++) {
1871
+ const n = nodes[i];
1872
+ const newNode = {
1873
+ id: nodeId(),
1874
+ type: resolvedTypes[i],
1875
+ title: n.title
1876
+ };
1877
+ if (n.description) newNode.description = n.description;
1878
+ if (n.tags) newNode.tags = normalizeTags(n.tags) ?? [];
1879
+ if (n.properties) newNode.properties = n.properties;
1880
+ if (n.status) {
1881
+ newNode.status = n.status;
1882
+ const sw = validateStatusAgainstLifecycle(newNode.type, n.status);
1883
+ if (sw) warnings.push(`Node "${n.title}": ${sw}`);
1884
+ } else {
1885
+ const ds = getDefaultStatus(newNode.type);
1886
+ if (ds) newNode.status = ds;
1887
+ }
1888
+ autoFillSlug(newNode, store);
1889
+ store.addNode(newNode);
1890
+ createdNodes.push({ id: newNode.id, type: newNode.type, title: newNode.title, status: newNode.status });
1891
+ createdNodeRefs.push(newNode);
1892
+ let parentId = n.parent_id;
1893
+ if (n.parent_ref !== void 0) {
1894
+ const refIndex = parseInt(n.parent_ref.slice(1), 10);
1895
+ parentId = createdNodes[refIndex].id;
1896
+ }
1897
+ if (parentId) {
1898
+ const parent = store.getNode(parentId);
1899
+ if (parent) {
1900
+ const inference = inferEdgeTypeWithTier(parent.type, newNode.type);
1901
+ if (inference.ok) {
1902
+ const edge = { id: edgeId(), source: parentId, target: newNode.id, type: inference.edgeType };
1903
+ store.addEdge(edge);
1904
+ createdParentEdges.push(edge);
1905
+ } else {
1906
+ const suggestion = inference.suggestions.length > 0 ? ` Suggestions: ${inference.suggestions.map((s) => `${s.source_type} \u2192 ${s.target_type} (${s.edge_type})`).join("; ")}.` : "";
1907
+ warnings.push(
1908
+ `Node "${newNode.title}": parent edge not created \u2014 no canonical edge for ${parent.type} \u2192 ${newNode.type}.${suggestion}`
1909
+ );
1910
+ }
1911
+ }
1912
+ }
1913
+ }
1914
+ for (const v of validatedEdges) {
1915
+ const sourceId = v.from.kind === "ref" ? createdNodes[v.from.index].id : v.from.id;
1916
+ const targetId = v.to.kind === "ref" ? createdNodes[v.to.index].id : v.to.id;
1917
+ let edgeType;
1918
+ if (v.typeOverride) {
1919
+ edgeType = v.typeOverride;
1920
+ } else {
1921
+ const source = store.getNode(sourceId);
1922
+ const target = store.getNode(targetId);
1923
+ const inference = inferEdgeTypeWithTier(source.type, target.type);
1924
+ if (!inference.ok) {
1925
+ throw new Error(`Edge inference unexpectedly failed for ${source.type} \u2192 ${target.type} (post-validation).`);
1926
+ }
1927
+ edgeType = inference.edgeType;
1928
+ }
1929
+ const newEdge = { id: edgeId(), source: sourceId, target: targetId, type: edgeType };
1930
+ store.addEdge(newEdge);
1931
+ explicitCreated.push(newEdge);
1932
+ }
1933
+ } catch (err) {
1934
+ rollbackAll();
1935
+ return {
1936
+ ok: false,
1937
+ error: `Atomic batch failed during apply: ${err.message}. All nodes and edges rolled back.`
1938
+ };
1939
+ }
1940
+ if (createdNodes.length >= 2 && createdParentEdges.length === 0 && explicitCreated.length === 0) {
1941
+ warnings.push(
1942
+ `Created ${createdNodes.length} nodes with no edges \u2014 they are orphans. Use the edges[] array in this call to link them. See get_entity_schema(<type>) for canonical edges per type.`
1943
+ );
1944
+ }
1945
+ const result = {
1946
+ ok: true,
1947
+ created: createdNodes,
1948
+ edges: createdParentEdges,
1949
+ count: createdNodes.length
1950
+ };
1951
+ if (explicitCreated.length > 0) result.explicit_edges = explicitCreated;
1952
+ if (warnings.length > 0) result.warnings = warnings;
1953
+ return result;
1954
+ }
1955
+ function migrateNodeType(store, args) {
1956
+ const node = store.getNode(args.node_id);
1957
+ if (!node) return { migrated: false, error: `Node not found: ${args.node_id}` };
1958
+ let resolved;
1959
+ try {
1960
+ resolved = resolveEntityType(args.new_type);
1961
+ } catch (err) {
1962
+ if (err instanceof UnknownEntityTypeError) {
1963
+ return { migrated: false, error: err.message, suggestions: err.suggestions };
1964
+ }
1965
+ throw err;
1966
+ }
1967
+ const oldType = node.type;
1968
+ const newType = resolved.canonical;
1969
+ const aliasWarning = resolved.alias ? `Type '${resolved.alias.from}' aliased to canonical '${resolved.alias.to}'.` : void 0;
1970
+ if (oldType === newType) {
1971
+ return {
1972
+ migrated: true,
1973
+ node_id: args.node_id,
1974
+ from_type: oldType,
1975
+ to_type: newType,
1976
+ edges_rewritten: [],
1977
+ ...aliasWarning ? { warning: aliasWarning } : {}
1978
+ };
1979
+ }
1980
+ const incident = store.getEdgesForNode(args.node_id);
1981
+ const plans = [];
1982
+ for (const e of incident) {
1983
+ if (e.type === args.new_type) continue;
1984
+ const sourceType = e.source === args.node_id ? newType : store.getNode(e.source)?.type ?? "";
1985
+ const targetType = e.target === args.node_id ? newType : store.getNode(e.target)?.type ?? "";
1986
+ if (!sourceType || !targetType) {
1987
+ return {
1988
+ migrated: false,
1989
+ error: `Edge ${e.id} references a missing node \u2014 fix graph integrity before migrating.`
1990
+ };
1991
+ }
1992
+ const inference = inferEdgeTypeWithTier(sourceType, targetType);
1993
+ if (!inference.ok) {
1994
+ const suggestion = inference.suggestions.length > 0 ? ` Suggestions: ${inference.suggestions.map((s) => `${s.source_type} \u2192 ${s.target_type} (${s.edge_type})`).join("; ")}.` : "";
1995
+ return {
1996
+ migrated: false,
1997
+ error: `Cannot re-infer edge ${e.id} (${e.type}) for ${sourceType} \u2192 ${targetType}.${suggestion} Delete the edge or pick an explicit edge type via update_node + create_edge.`
1998
+ };
1999
+ }
2000
+ if (inference.edgeType !== e.type) {
2001
+ plans.push({ oldEdge: e, newType: inference.edgeType });
2002
+ }
2003
+ }
2004
+ const removed = [];
2005
+ const added = [];
2006
+ try {
2007
+ for (const p of plans) {
2008
+ const old = store.removeEdge(p.oldEdge.id);
2009
+ removed.push(old);
2010
+ }
2011
+ store.updateNode(args.node_id, { type: newType });
2012
+ for (const p of plans) {
2013
+ const newEdge = {
2014
+ id: edgeId(),
2015
+ source: p.oldEdge.source,
2016
+ target: p.oldEdge.target,
2017
+ type: p.newType
2018
+ };
2019
+ store.addEdge(newEdge);
2020
+ added.push(newEdge);
2021
+ }
2022
+ } catch (err) {
2023
+ for (const a of added.slice().reverse()) {
2024
+ try {
2025
+ store.removeEdge(a.id);
2026
+ } catch {
2027
+ }
2028
+ }
2029
+ try {
2030
+ store.updateNode(args.node_id, { type: oldType });
2031
+ } catch {
2032
+ }
2033
+ for (const r of removed.slice().reverse()) {
2034
+ try {
2035
+ store.addEdge(r, true);
2036
+ } catch {
2037
+ }
2038
+ }
2039
+ return {
2040
+ migrated: false,
2041
+ error: `Migration failed mid-apply: ${err.message}. Graph rolled back.`
2042
+ };
2043
+ }
2044
+ const edgesRewritten = plans.map((p, i) => ({
2045
+ id: added[i].id,
2046
+ from: p.oldEdge.type,
2047
+ to: p.newType
2048
+ }));
2049
+ return {
2050
+ migrated: true,
2051
+ node_id: args.node_id,
2052
+ from_type: oldType,
2053
+ to_type: newType,
2054
+ edges_rewritten: edgesRewritten,
2055
+ ...aliasWarning ? { warning: aliasWarning } : {}
2056
+ };
2057
+ }
2058
+
2059
+ // src/client.ts
2060
+ var UPGClient = class {
2061
+ options;
2062
+ store = null;
2063
+ loadPromise = null;
2064
+ /** Node operations namespace. */
2065
+ nodes;
2066
+ /** Edge operations namespace. */
2067
+ edges;
2068
+ constructor(options) {
2069
+ this.options = options;
2070
+ this.nodes = new NodesAPI(this);
2071
+ this.edges = new EdgesAPI(this);
2072
+ }
2073
+ /**
2074
+ * Load the .upg file. Called automatically on first operation unless
2075
+ * `{ lazy: true }` was set. Safe to call multiple times — repeated calls
2076
+ * are coalesced. If load fails (transient I/O, parse error, etc.) the
2077
+ * promise is rejected AND the cached promise is cleared, so the next
2078
+ * call retries from scratch rather than re-throwing the stale error.
2079
+ */
2080
+ async load() {
2081
+ if (this.store && !this.loadPromise) return;
2082
+ if (this.loadPromise) return this.loadPromise;
2083
+ this.loadPromise = (async () => {
2084
+ try {
2085
+ const store = new UPGFileStore();
2086
+ await store.load(this.options.file);
2087
+ this.store = store;
2088
+ } catch (err) {
2089
+ this.loadPromise = null;
2090
+ throw err;
2091
+ }
2092
+ })();
2093
+ return this.loadPromise;
2094
+ }
2095
+ /** Internal: get the loaded store, loading on demand. */
2096
+ async getStore() {
2097
+ if (!this.store) await this.load();
2098
+ if (!this.store) throw new Error("UPGClient: store failed to load");
2099
+ return this.store;
2100
+ }
2101
+ /** Persist pending changes to disk. Called automatically after mutations. */
2102
+ async flush() {
2103
+ const store = await this.getStore();
2104
+ await store.flush();
2105
+ }
2106
+ /** Compute a health score (0–100) plus the underlying graph digest. */
2107
+ async health() {
2108
+ const store = await this.getStore();
2109
+ const digest = computeGraphDigest(store);
2110
+ return { score: computeHealthScore(digest), digest };
2111
+ }
2112
+ /** Search nodes by free-text query. */
2113
+ async search(query, options = {}) {
2114
+ const store = await this.getStore();
2115
+ return searchNodes(store, query, {
2116
+ limit: options.limit ?? 20,
2117
+ ...options.type ? { type: options.type } : {}
2118
+ });
2119
+ }
2120
+ /**
2121
+ * Verify integrity of the loaded graph. Returns the integrity report from
2122
+ * the last load + any in-memory mutation checks. `null` indicates no
2123
+ * integrity issues were detected.
2124
+ */
2125
+ async verify() {
2126
+ const store = await this.getStore();
2127
+ return store.getIntegrityReport();
2128
+ }
2129
+ /**
2130
+ * Diff against a previous version. Not yet implemented — tracked in
2131
+ * UPG-541 follow-up. Will return a structured changeset between the
2132
+ * current graph and the named ref (git revision or snapshot id).
2133
+ */
2134
+ async diff(_ref) {
2135
+ throw new Error(
2136
+ "UPGClient.diff() is not yet implemented. Use the CLI (`upg diff <ref>`) for now."
2137
+ );
2138
+ }
2139
+ /** Release file watchers and free resources. */
2140
+ async close() {
2141
+ if (!this.store) return;
2142
+ await this.store.flush();
2143
+ this.store = null;
2144
+ this.loadPromise = null;
2145
+ }
2146
+ };
2147
+ var NodesAPI = class {
2148
+ constructor(client) {
2149
+ this.client = client;
2150
+ }
2151
+ client;
2152
+ /**
2153
+ * Create a node. The `type` is validated against the UPG entity catalog —
2154
+ * deprecated aliases are accepted with a warning, genuinely unknown types
2155
+ * throw `UnknownEntityTypeError`.
2156
+ */
2157
+ async create(args) {
2158
+ const store = await this.client.getStore();
2159
+ const result = createNode(store, args);
2160
+ await store.flush();
2161
+ return result;
2162
+ }
2163
+ /** List nodes, optionally filtered by type / status / tag. */
2164
+ async list(options = {}) {
2165
+ const store = await this.client.getStore();
2166
+ return listNodes(store, options);
2167
+ }
2168
+ /** Get a single node by id. Returns `undefined` if not found. */
2169
+ async get(id) {
2170
+ const store = await this.client.getStore();
2171
+ const result = getNode(store, { node_id: id });
2172
+ return result?.node;
2173
+ }
2174
+ /** Update a node by id. Returns the updated node. */
2175
+ async update(id, patch) {
2176
+ const store = await this.client.getStore();
2177
+ const updated = store.updateNode(id, patch);
2178
+ await store.flush();
2179
+ return updated;
2180
+ }
2181
+ /** Delete a node and all incident edges. */
2182
+ async delete(id) {
2183
+ const store = await this.client.getStore();
2184
+ const result = deleteNode(store, { node_id: id });
2185
+ await store.flush();
2186
+ return result;
2187
+ }
2188
+ };
2189
+ var EdgesAPI = class {
2190
+ constructor(client) {
2191
+ this.client = client;
2192
+ }
2193
+ client;
2194
+ /**
2195
+ * Connect two nodes with an edge. Edge type is inferred from source +
2196
+ * target entity types if not provided.
2197
+ */
2198
+ async connect(sourceId, targetId, opts = {}) {
2199
+ const store = await this.client.getStore();
2200
+ const args = {
2201
+ source_id: sourceId,
2202
+ target_id: targetId,
2203
+ ...opts
2204
+ };
2205
+ const result = createEdge(store, args);
2206
+ await store.flush();
2207
+ return result;
2208
+ }
2209
+ /** List edges, optionally filtered by source / target / type. */
2210
+ async list(options = {}) {
2211
+ const store = await this.client.getStore();
2212
+ let edges = store.getAllEdges();
2213
+ if (options.source) edges = edges.filter((e) => e.source === options.source);
2214
+ if (options.target) edges = edges.filter((e) => e.target === options.target);
2215
+ if (options.type) edges = edges.filter((e) => e.type === options.type);
2216
+ return edges;
2217
+ }
2218
+ /** Delete an edge by id. */
2219
+ async delete(id) {
2220
+ const store = await this.client.getStore();
2221
+ const result = deleteEdge(store, { edge_id: id });
2222
+ await store.flush();
2223
+ return result;
2224
+ }
2225
+ };
2226
+
2227
+ // src/lib/workspace.ts
2228
+ import * as fsp from "fs/promises";
2229
+ import * as path2 from "path";
2230
+ import { createHash as createHash2 } from "crypto";
2231
+ var WorkspaceAlreadyExistsError = class extends Error {
2232
+ constructor() {
2233
+ super("Workspace already exists. Use get_workspace_info to see current state.");
2234
+ this.name = "WorkspaceAlreadyExistsError";
2235
+ }
2236
+ };
2237
+ var WorkspaceNotInitialisedError = class extends Error {
2238
+ constructor() {
2239
+ super(
2240
+ "Workspace not initialised. Run `init_workspace` first to enable multi-product management."
2241
+ );
2242
+ this.name = "WorkspaceNotInitialisedError";
2243
+ }
2244
+ };
2245
+ var InvalidProductNameError = class extends Error {
2246
+ constructor(reason) {
2247
+ super(`Invalid product name: ${reason}`);
2248
+ this.name = "InvalidProductNameError";
2249
+ }
2250
+ };
2251
+ var InvalidProductStageError = class extends Error {
2252
+ constructor(message) {
2253
+ super(message);
2254
+ this.name = "InvalidProductStageError";
2255
+ }
2256
+ };
2257
+ async function readProductTitle(filePath, fallback) {
2258
+ try {
2259
+ const raw = await fsp.readFile(filePath, "utf-8");
2260
+ const doc = JSON.parse(raw);
2261
+ if (doc.product?.title && typeof doc.product.title === "string") {
2262
+ return doc.product.title;
2263
+ }
2264
+ } catch {
2265
+ }
2266
+ return fallback;
2267
+ }
2268
+ async function initWorkspace({
2269
+ cwd,
2270
+ store,
2271
+ moveExisting = true
2272
+ }) {
2273
+ const resolvedCwd = path2.resolve(cwd);
2274
+ const upgDir = path2.resolve(resolvedCwd, ".upg");
2275
+ try {
2276
+ await fsp.access(path2.join(upgDir, "workspace.json"));
2277
+ throw new WorkspaceAlreadyExistsError();
2278
+ } catch (err) {
2279
+ if (err instanceof WorkspaceAlreadyExistsError) throw err;
2280
+ }
2281
+ await fsp.mkdir(upgDir, { recursive: true });
2282
+ const rootEntries = await fsp.readdir(resolvedCwd, { withFileTypes: true });
2283
+ const rootUpgFiles = rootEntries.filter((e) => e.isFile() && e.name.endsWith(".upg")).map((e) => e.name).sort();
2284
+ let preExistingFiles = [];
2285
+ try {
2286
+ const upgDirEntries = await fsp.readdir(upgDir, { withFileTypes: true });
2287
+ preExistingFiles = upgDirEntries.filter((e) => e.isFile() && e.name.endsWith(".upg")).map((e) => e.name).sort();
2288
+ } catch {
2289
+ }
2290
+ const products = [];
2291
+ const seen = /* @__PURE__ */ new Set();
2292
+ for (const file of preExistingFiles) {
2293
+ const filePath = path2.join(upgDir, file);
2294
+ const title = await readProductTitle(filePath, path2.basename(file, ".upg"));
2295
+ products.push({ file, title });
2296
+ seen.add(file);
2297
+ }
2298
+ if (moveExisting) {
2299
+ for (const file of rootUpgFiles) {
2300
+ const srcPath = path2.resolve(resolvedCwd, file);
2301
+ const destPath = path2.resolve(upgDir, file);
2302
+ if (srcPath === destPath || path2.dirname(srcPath) === upgDir) {
2303
+ if (!seen.has(file)) {
2304
+ const title2 = await readProductTitle(srcPath, path2.basename(file, ".upg"));
2305
+ products.push({ file, title: title2 });
2306
+ seen.add(file);
2307
+ }
2308
+ continue;
2309
+ }
2310
+ const title = await readProductTitle(srcPath, path2.basename(file, ".upg"));
2311
+ let destExists = false;
2312
+ try {
2313
+ await fsp.access(destPath);
2314
+ destExists = true;
2315
+ } catch {
2316
+ }
2317
+ if (!destExists) {
2318
+ await fsp.rename(srcPath, destPath);
2319
+ }
2320
+ if (!seen.has(file)) {
2321
+ products.push({ file, title });
2322
+ seen.add(file);
2323
+ }
2324
+ }
2325
+ }
2326
+ if (products.length === 0) {
2327
+ const currentFile = store.getFilePath();
2328
+ const basename2 = path2.basename(currentFile);
2329
+ const destPath = path2.resolve(upgDir, basename2);
2330
+ try {
2331
+ await fsp.access(destPath);
2332
+ } catch {
2333
+ await fsp.copyFile(currentFile, destPath);
2334
+ }
2335
+ const product2 = store.getProduct();
2336
+ products.push({ file: basename2, title: product2.title });
2337
+ }
2338
+ const defaultProduct = products[0].file;
2339
+ await fsp.writeFile(
2340
+ path2.join(upgDir, "workspace.json"),
2341
+ JSON.stringify({ version: "1.0", default_product: defaultProduct, products }, null, 2) + "\n",
2342
+ "utf-8"
2343
+ );
2344
+ const newFilePath = path2.join(upgDir, defaultProduct);
2345
+ await store.flush();
2346
+ store.stopWatching();
2347
+ await store.load(newFilePath);
2348
+ const product = store.getProduct();
2349
+ return {
2350
+ workspace_path: ".upg/",
2351
+ default_product: defaultProduct,
2352
+ products,
2353
+ current_product: { title: product.title, entities: store.getAllNodes().length }
2354
+ };
2355
+ }
2356
+ function computeIntegrityChecksum(doc) {
2357
+ const sortedNodes = [...doc.nodes].sort((a, b) => a.id.localeCompare(b.id));
2358
+ const sortedEdges = [...doc.edges].sort((a, b) => a.id.localeCompare(b.id));
2359
+ const content = JSON.stringify({ nodes: sortedNodes, edges: sortedEdges });
2360
+ return createHash2("sha256").update(content).digest("hex").slice(0, 32);
2361
+ }
2362
+ async function createProduct(args) {
2363
+ const { cwd, store, name, slug: slugArg, description, stage, portfolio_id } = args;
2364
+ const upgDir = path2.resolve(cwd, ".upg");
2365
+ try {
2366
+ await fsp.access(path2.join(upgDir, "workspace.json"));
2367
+ } catch {
2368
+ throw new WorkspaceNotInitialisedError();
2369
+ }
2370
+ if (typeof name !== "string" || name.trim().length === 0) {
2371
+ throw new InvalidProductNameError("name must be a non-empty string");
2372
+ }
2373
+ const trimmedName = name.trim();
2374
+ if (stage !== void 0) {
2375
+ const stageError = validateProductStageStrict(stage);
2376
+ if (stageError !== null) {
2377
+ throw new InvalidProductStageError(stageError);
2378
+ }
2379
+ }
2380
+ const upgDirEntries = await fsp.readdir(upgDir, { withFileTypes: true });
2381
+ const existingSlugs = new Set(
2382
+ upgDirEntries.filter((e) => e.isFile() && e.name.endsWith(".upg")).map((e) => path2.basename(e.name, ".upg"))
2383
+ );
2384
+ const baseSlug = slugArg && slugArg.trim().length > 0 ? generateSlug(slugArg) : generateSlug(trimmedName);
2385
+ const slug = resolveSlugCollision(baseSlug, existingSlugs);
2386
+ const filename = `${slug}.upg`;
2387
+ const destPath = path2.join(upgDir, filename);
2388
+ const newProductId = productId();
2389
+ const newDoc = {
2390
+ upg_version: UPG_VERSION,
2391
+ exported_at: (/* @__PURE__ */ new Date()).toISOString(),
2392
+ source: { tool: "upg-mcp-local" },
2393
+ product: {
2394
+ id: newProductId,
2395
+ title: trimmedName,
2396
+ ...description ? { description } : {},
2397
+ ...stage ? { stage } : {}
2398
+ },
2399
+ nodes: [],
2400
+ edges: []
2401
+ };
2402
+ newDoc._integrity = {
2403
+ checksum: computeIntegrityChecksum(newDoc),
2404
+ verified_at: (/* @__PURE__ */ new Date()).toISOString(),
2405
+ verified_by: "upg-mcp-local"
2406
+ };
2407
+ try {
2408
+ await fsp.access(destPath);
2409
+ throw new Error(`File already exists at ${destPath} \u2014 slug resolution failed`);
2410
+ } catch (err) {
2411
+ if (err.code !== "ENOENT") throw err;
2412
+ }
2413
+ await fsp.writeFile(destPath, JSON.stringify(newDoc, null, 2) + "\n", "utf-8");
2414
+ const workspacePath = path2.join(upgDir, "workspace.json");
2415
+ const workspaceRaw = await fsp.readFile(workspacePath, "utf-8");
2416
+ const workspace = JSON.parse(workspaceRaw);
2417
+ workspace.products = [
2418
+ ...workspace.products,
2419
+ { file: filename, title: trimmedName }
2420
+ ];
2421
+ await fsp.writeFile(
2422
+ workspacePath,
2423
+ JSON.stringify(workspace, null, 2) + "\n",
2424
+ "utf-8"
2425
+ );
2426
+ let portfolioAttached = false;
2427
+ if (portfolio_id) {
2428
+ const portfolio = store.getNode(portfolio_id);
2429
+ if (portfolio && portfolio.type === "portfolio") {
2430
+ store.addNode({
2431
+ id: newProductId,
2432
+ type: "product",
2433
+ title: trimmedName,
2434
+ ...description ? { description } : {}
2435
+ });
2436
+ store.addEdge({
2437
+ id: edgeId(),
2438
+ source: portfolio_id,
2439
+ target: newProductId,
2440
+ type: "portfolio_contains_product"
2441
+ });
2442
+ portfolioAttached = true;
2443
+ }
2444
+ }
2445
+ return {
2446
+ id: newProductId,
2447
+ file: filename,
2448
+ slug,
2449
+ title: trimmedName,
2450
+ workspace_path: ".upg/",
2451
+ portfolio_attached: portfolioAttached
2452
+ };
2453
+ }
2454
+
2455
+ // src/lib/portfolio-routing.ts
2456
+ import * as fs2 from "fs";
2457
+ import * as path3 from "path";
2458
+ import { createHash as createHash3 } from "crypto";
2459
+ var PORTFOLIO_SCOPED_TYPES = /* @__PURE__ */ new Set([
2460
+ "portfolio",
2461
+ "organization",
2462
+ "product_area"
2463
+ ]);
2464
+ var PORTFOLIO_FILENAME = "portfolio.upg";
2465
+ function isPortfolioScopedType(type) {
2466
+ return PORTFOLIO_SCOPED_TYPES.has(type);
2467
+ }
2468
+ function resolvePortfolioPath(cwd) {
2469
+ const upgDir = path3.join(cwd, ".upg");
2470
+ if (!fs2.existsSync(upgDir)) return null;
2471
+ return path3.join(upgDir, PORTFOLIO_FILENAME);
2472
+ }
2473
+ function resolveOrCreatePortfolioPath(cwd) {
2474
+ const upgDir = path3.join(cwd, ".upg");
2475
+ if (!fs2.existsSync(upgDir)) {
2476
+ fs2.mkdirSync(upgDir, { recursive: true });
2477
+ }
2478
+ return path3.join(upgDir, PORTFOLIO_FILENAME);
2479
+ }
2480
+ async function openPortfolioStore(portfolioPath) {
2481
+ const store = new UPGPortfolioStore();
2482
+ await store.loadOrInit(portfolioPath);
2483
+ return store;
2484
+ }
2485
+ var PortfolioRoutingError = class extends Error {
2486
+ constructor(message) {
2487
+ super(message);
2488
+ this.name = "PortfolioRoutingError";
2489
+ }
2490
+ };
2491
+ async function writePortfolioScopedNode(cwd, args) {
2492
+ if (!isPortfolioScopedType(args.type)) {
2493
+ throw new PortfolioRoutingError(
2494
+ `writePortfolioScopedNode called with non-portfolio type "${args.type}". Valid types: ${[...PORTFOLIO_SCOPED_TYPES].join(", ")}.`
2495
+ );
2496
+ }
2497
+ const portfolioPath = resolveOrCreatePortfolioPath(cwd);
2498
+ const store = await openPortfolioStore(portfolioPath);
2499
+ const doc = store.getDocument();
2500
+ if (!doc) {
2501
+ throw new PortfolioRoutingError(
2502
+ `Failed to initialise portfolio document at ${portfolioPath}`
2503
+ );
2504
+ }
2505
+ let result;
2506
+ switch (args.type) {
2507
+ case "portfolio":
2508
+ result = appendPortfolio(doc, args, portfolioPath);
2509
+ break;
2510
+ case "product_area":
2511
+ result = appendProductArea(doc, args, portfolioPath);
2512
+ break;
2513
+ case "organization":
2514
+ result = setOrganization(doc, args, portfolioPath);
2515
+ break;
2516
+ default:
2517
+ throw new PortfolioRoutingError(`Unhandled portfolio-scoped type: ${args.type}`);
2518
+ }
2519
+ store.markDirty();
2520
+ await store.flush();
2521
+ return result;
2522
+ }
2523
+ function appendPortfolio(doc, args, portfolioPath) {
2524
+ const props = args.properties ?? {};
2525
+ const entity = {
2526
+ id: nodeId(),
2527
+ title: args.title
2528
+ };
2529
+ if (args.description) entity.description = args.description;
2530
+ if (typeof props.parent_portfolio_id === "string" || props.parent_portfolio_id === null) {
2531
+ entity.parent_portfolio_id = props.parent_portfolio_id;
2532
+ }
2533
+ if (props.hierarchy_model === "flat" || props.hierarchy_model === "nested" || props.hierarchy_model === "matrix") {
2534
+ entity.hierarchy_model = props.hierarchy_model;
2535
+ }
2536
+ if (Array.isArray(props.products)) {
2537
+ entity.products = props.products.filter((p) => typeof p === "string");
2538
+ }
2539
+ doc.portfolios.push(entity);
2540
+ return { entity, written_to: "portfolios", portfolio_file: portfolioPath };
2541
+ }
2542
+ function appendProductArea(doc, args, portfolioPath) {
2543
+ const props = args.properties ?? {};
2544
+ const entity = {
2545
+ id: nodeId(),
2546
+ title: args.title
2547
+ };
2548
+ if (args.description) entity.description = args.description;
2549
+ if (typeof props.parent_area_id === "string" || props.parent_area_id === null) {
2550
+ entity.parent_area_id = props.parent_area_id;
2551
+ }
2552
+ if (props.strategic_priority === "critical" || props.strategic_priority === "high" || props.strategic_priority === "medium" || props.strategic_priority === "low") {
2553
+ entity.strategic_priority = props.strategic_priority;
2554
+ }
2555
+ if (Array.isArray(props.products)) {
2556
+ entity.products = props.products.filter((p) => typeof p === "string");
2557
+ }
2558
+ doc.product_areas.push(entity);
2559
+ return { entity, written_to: "product_areas", portfolio_file: portfolioPath };
2560
+ }
2561
+ function setOrganization(doc, args, portfolioPath) {
2562
+ const props = args.properties ?? {};
2563
+ const existing = doc.organization;
2564
+ const isPlaceholder = isPlaceholderOrganization(existing);
2565
+ if (existing && !isPlaceholder && !args.overwrite_organization) {
2566
+ throw new PortfolioRoutingError(
2567
+ `Portfolio already has an organization (id: "${existing.id}", title: "${existing.title}"). A portfolio holds exactly one organization. Pass overwrite_organization: true to replace it (e.g. create_node({type: "organization", title: "...", overwrite_organization: true})), or call update_node on the existing organization id.`
2568
+ );
2569
+ }
2570
+ const entity = {
2571
+ id: `org_${createHash3("sha256").update(args.title).digest("hex").slice(0, 8)}`,
2572
+ title: args.title
2573
+ };
2574
+ if (args.description) entity.description = args.description;
2575
+ if (typeof props.logo_url === "string") entity.logo_url = props.logo_url;
2576
+ if (typeof props.industry === "string") entity.industry = props.industry;
2577
+ const warning = !isPlaceholder && args.overwrite_organization ? `Replaced existing organization "${existing.title}" (id: ${existing.id}) with "${entity.title}" (id: ${entity.id}).` : void 0;
2578
+ doc.organization = entity;
2579
+ return {
2580
+ entity,
2581
+ written_to: "organization",
2582
+ portfolio_file: portfolioPath,
2583
+ ...warning ? { warning } : {}
2584
+ };
2585
+ }
2586
+ function isPlaceholderOrganization(org) {
2587
+ if (!org) return true;
2588
+ if (org.title !== "Portfolio") return false;
2589
+ const placeholderId = `org_${createHash3("sha256").update("Portfolio").digest("hex").slice(0, 8)}`;
2590
+ return org.id === placeholderId;
2591
+ }
2592
+ async function openPortfolioStoreIfExists(cwd) {
2593
+ const portfolioPath = resolvePortfolioPath(cwd);
2594
+ if (!portfolioPath) return null;
2595
+ if (!fs2.existsSync(portfolioPath)) return null;
2596
+ const store = new UPGPortfolioStore();
2597
+ await store.loadOrInit(portfolioPath);
2598
+ return store;
2599
+ }
2600
+ function registerProductOnPortfolio(doc, ref) {
2601
+ if (!ref.id) return false;
2602
+ const products = doc.products;
2603
+ if (products.some((p) => p.id === ref.id)) return false;
2604
+ const entry = { id: ref.id };
2605
+ if (ref.file_path) entry.file_path = ref.file_path;
2606
+ if (ref.title) entry.title = ref.title;
2607
+ products.push(entry);
2608
+ return true;
2609
+ }
2610
+ function findProductFileById(cwd, productId2) {
2611
+ const upgDir = path3.join(cwd, ".upg");
2612
+ if (!fs2.existsSync(upgDir)) return null;
2613
+ let entries;
2614
+ try {
2615
+ entries = fs2.readdirSync(upgDir, { withFileTypes: true });
2616
+ } catch {
2617
+ return null;
2618
+ }
2619
+ for (const entry of entries) {
2620
+ if (!entry.isFile() || !entry.name.endsWith(".upg")) continue;
2621
+ if (entry.name === PORTFOLIO_FILENAME) continue;
2622
+ const filePath = path3.join(upgDir, entry.name);
2623
+ try {
2624
+ const raw = fs2.readFileSync(filePath, "utf-8");
2625
+ const doc = JSON.parse(raw);
2626
+ if (doc.product?.id === productId2) {
2627
+ return {
2628
+ file_path: path3.relative(cwd, filePath),
2629
+ title: doc.product.title ?? entry.name
2630
+ };
2631
+ }
2632
+ } catch {
2633
+ }
2634
+ }
2635
+ return null;
2636
+ }
2637
+ export {
2638
+ BUSINESS_AREAS,
2639
+ BUSINESS_AREA_META,
2640
+ CHAINS,
2641
+ DESCRIPTION_SOFT_LIMIT,
2642
+ ENTITY_CLASSIFICATION,
2643
+ InferEdgeTypeError,
2644
+ InvalidProductNameError,
2645
+ InvalidProductStageError,
2646
+ LIFECYCLE_PHASES,
2647
+ PORTFOLIO_FILENAME,
2648
+ PORTFOLIO_SCOPED_TYPES,
2649
+ PROPERTY_TREE_SOFT_BYTES,
2650
+ PROPERTY_TREE_SOFT_DEPTH,
2651
+ PortfolioRoutingError,
2652
+ STAGE_COVERAGE_TARGETS,
2653
+ TIER_CLUSTER_META,
2654
+ TIER_DESCRIPTIONS,
2655
+ TITLE_SOFT_LIMIT,
2656
+ UPGClient,
2657
+ UPGFileStore,
2658
+ UPGPortfolioStore,
2659
+ UPG_ANTI_PATTERNS,
2660
+ UPG_REGIONS,
2661
+ UnknownEntityTypeError,
2662
+ WorkspaceAlreadyExistsError,
2663
+ WorkspaceNotInitialisedError,
2664
+ autoFillSlug,
2665
+ batchCreateNodes,
2666
+ batchMoveNodes,
2667
+ buildAdjacentEdges,
2668
+ buildAlternateAnchors,
2669
+ buildAnchorHint,
2670
+ buildResolverHints,
2671
+ checkLengthCaps,
2672
+ checkPropertyTypes,
2673
+ classifyDanglingEdges,
2674
+ collectAntiPatternInputs,
2675
+ computeGraphDigest,
2676
+ computeHealthScore,
2677
+ computeSchemaDriftSummary,
2678
+ createEdge,
2679
+ createNode,
2680
+ createProduct,
2681
+ deleteEdge,
2682
+ deleteNode,
2683
+ edgeId,
2684
+ evaluateExpression,
2685
+ executeInspect,
2686
+ executePlan,
2687
+ executePrioritise,
2688
+ executeReflect,
2689
+ executeTrace,
2690
+ findProductFileById,
2691
+ getClassification,
2692
+ getDefaultStatus,
2693
+ getEntitiesForBusinessArea,
2694
+ getEntitiesForCluster,
2695
+ getEntitiesForTier,
2696
+ getEntitiesForTierAndArea,
2697
+ getEntitiesUpToTier,
2698
+ getNode,
2699
+ getNodes,
2700
+ getOrphans,
2701
+ getTierEntityCount,
2702
+ getTierForStage,
2703
+ inferEdgeType,
2704
+ inferEdgeTypeWithTier,
2705
+ initWorkspace,
2706
+ isPortfolioScopedType,
2707
+ isRelevantForStage,
2708
+ listNodes,
2709
+ migrateNodeType,
2710
+ moveNode,
2711
+ nodeId,
2712
+ normalizeTags,
2713
+ openPortfolioStore,
2714
+ openPortfolioStoreIfExists,
2715
+ productId,
2716
+ registerProductOnPortfolio,
2717
+ renderDanglingReport,
2718
+ renderDriftSummary,
2719
+ renderPropertyTypeWarning,
2720
+ resolveCoverageStage,
2721
+ resolveEntityType,
2722
+ resolveOrCreatePortfolioPath,
2723
+ resolvePortfolioPath,
2724
+ searchNodes,
2725
+ sortByType,
2726
+ typeSortPriority,
2727
+ validateClassificationCoverage,
2728
+ validateEdgeTypePair,
2729
+ validateStatusAgainstLifecycle,
2730
+ writePortfolioScopedNode
2731
+ };
2732
+ //# sourceMappingURL=index.js.map