@unified-product-graph/mcp-server 0.9.0 → 0.9.2

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 CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { parseArgs } from "util";
5
- import * as fs3 from "fs/promises";
6
- import * as path6 from "path";
7
- import { UPGFileStore } from "@unified-product-graph/sdk";
5
+ import * as fs4 from "fs/promises";
6
+ import * as path7 from "path";
7
+ import { UPGFileStore as UPGFileStore2 } from "@unified-product-graph/sdk";
8
8
 
9
9
  // src/server.ts
10
10
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
- import fs2 from "fs";
12
+ import fs3 from "fs";
13
13
  import { fileURLToPath as fileURLToPath2 } from "url";
14
- import * as path5 from "path";
14
+ import * as path6 from "path";
15
15
  import {
16
16
  CallToolRequestSchema,
17
17
  ListToolsRequestSchema
@@ -1303,8 +1303,24 @@ var UPG_EDGE_CATALOG = {
1303
1303
  journey_step_realised_by_feature: { forward_verb: "realised_by", reverse_verb: "realises", classification: "cross-domain", source_type: "journey_step", target_type: "feature" },
1304
1304
  opportunity_improves_user_journey: { forward_verb: "improves", reverse_verb: "improved_by", classification: "cross-domain", source_type: "opportunity", target_type: "user_journey" },
1305
1305
  user_journey_passes_through_journey_phase: { forward_verb: "passes_through", reverse_verb: "is_phase_of", classification: "hierarchy", source_type: "user_journey", target_type: "journey_phase" },
1306
- journey_phase_has_step: { forward_verb: "has_step", reverse_verb: "is_step_in", classification: "hierarchy", source_type: "journey_phase", target_type: "journey_step" },
1306
+ // (since v0.9.2, UPG-663) A journey_phase is a temporal BAND over the
1307
+ // journey's single step timeline, not a container that owns steps. Steps
1308
+ // belong to the journey via `user_journey_contains_journey_step` (the stable
1309
+ // 0.1.0 spine); a phase merely SPANS a range of them. Mirrors the marketing
1310
+ // precedent `customer_journey_stage_spans_journey_step`. Renamed from the
1311
+ // owning `journey_phase_has_step` (hierarchy) so a step has exactly one
1312
+ // containment parent. See UPG_EDGE_MIGRATIONS['0.9.2'].
1313
+ journey_phase_spans_journey_step: { forward_verb: "spans", reverse_verb: "spanned_by", classification: "cross-domain", source_type: "journey_phase", target_type: "journey_step" },
1307
1314
  journey_step_has_action: { forward_verb: "has_action", reverse_verb: "is_action_in", classification: "hierarchy", source_type: "journey_step", target_type: "journey_action" },
1315
+ // (since v0.9.2, UPG-663) journey_action outbound edges. Fixes the
1316
+ // discovery dead-end (D2). The finest blueprint layer carries pain_score /
1317
+ // opportunity_score "to drive opportunity discovery" but previously had zero
1318
+ // outbound edges. Opportunity discovery routes through `need` (mirroring
1319
+ // `journey_step_reveals_need`), which already reaches `opportunity` via the
1320
+ // user chain; the action does not link an opportunity directly. The feature
1321
+ // edge mirrors `journey_step_realised_by_feature` one level deeper.
1322
+ journey_action_surfaces_need: { forward_verb: "surfaces", reverse_verb: "surfaced_in", classification: "cross-domain", source_type: "journey_action", target_type: "need" },
1323
+ journey_action_realised_by_feature: { forward_verb: "realised_by", reverse_verb: "realises", classification: "cross-domain", source_type: "journey_action", target_type: "feature" },
1308
1324
  // 2.5 UI System Domain
1309
1325
  product_systematised_in_design_system: { forward_verb: "systematised_in", reverse_verb: "systematises", classification: "hierarchy", source_type: "product", target_type: "design_system" },
1310
1326
  product_built_with_design_component: { forward_verb: "built_with", reverse_verb: "built_for", classification: "hierarchy", source_type: "product", target_type: "design_component" },
@@ -2741,7 +2757,8 @@ var UPG_CROSS_EDGE_TYPES = [
2741
2757
  "depends_on_product",
2742
2758
  "cannibalises",
2743
2759
  "succeeds",
2744
- "hosts"
2760
+ "hosts",
2761
+ "contributes_to"
2745
2762
  ];
2746
2763
  var TYPES = getTypes();
2747
2764
  var TYPES_SET = new Set(TYPES);
@@ -3028,8 +3045,13 @@ var UPG_VALID_CHILDREN = {
3028
3045
  // parallel to `observation: ['quote']`. Research-synthesis canon.
3029
3046
  // Pairs with `insight_evidenced_by_quote` in the edge catalog.
3030
3047
  // ── Design hierarchy ────────────────────────────────────────────────────────
3048
+ // (UPG-663, v0.9.2) A user_journey owns its steps (the stable 0.1.0 spine)
3049
+ // and carries phases as a non-owning band overlay. A journey_phase is NO
3050
+ // LONGER a containment parent of journey_step: it SPANS steps via the
3051
+ // `journey_phase_spans_journey_step` edge (mirroring the marketing
3052
+ // `customer_journey_stage_spans_journey_step` precedent), so each step has
3053
+ // exactly one containment parent and the journey has one canonical step list.
3031
3054
  user_journey: ["journey_step", "journey_phase"],
3032
- journey_phase: ["journey_step"],
3033
3055
  journey_step: ["journey_action"],
3034
3056
  // v0.5.8 (UPG-528 Part 2d): insights own their evidencing quotes;
3035
3057
  // parallel to `observation: ['quote']`. Research-synthesis canon
@@ -7126,6 +7148,20 @@ var UPG_SPLIT_MIGRATIONS = {
7126
7148
  ]
7127
7149
  };
7128
7150
  var UPG_EDGE_MIGRATIONS = {
7151
+ "0.9.2": [
7152
+ // (since v0.9.2, UPG-663) Journey-model disambiguation. A journey_phase
7153
+ // is a temporal BAND over the journey's single step timeline, not a
7154
+ // container that owns steps. The owning hierarchy edge
7155
+ // `journey_phase_has_step` is renamed to the non-owning
7156
+ // `journey_phase_spans_journey_step` (mirroring the marketing precedent
7157
+ // `customer_journey_stage_spans_journey_step`). Steps stay owned by the
7158
+ // journey via `user_journey_contains_journey_step` (the stable 0.1.0
7159
+ // spine), so each step keeps a single containment parent and the journey
7160
+ // renders one canonical step list. Endpoints are unchanged
7161
+ // (journey_phase → journey_step); only the edge key and its grammar
7162
+ // (verb has_step → spans, classification hierarchy → cross-domain) change.
7163
+ { kind: "rename", from: "journey_phase_has_step", to: "journey_phase_spans_journey_step", requires_source_type: "journey_phase", requires_target_type: "journey_step", reason: "Journey-model disambiguation (UPG-663): a phase spans steps, it does not own them. The phase to step edge becomes non-owning (verb has_step to spans), so the step keeps a single containment parent (the journey) and the journey has one canonical step list." }
7164
+ ],
7129
7165
  "0.9.0": [
7130
7166
  // (since v0.9.0, UPG-660) theme → roadmap_theme. The four canonical edges that
7131
7167
  // touch the roadmap theme are renamed to the roadmap_theme form. Endpoint guards
@@ -9544,6 +9580,7 @@ var UPG_PROPERTY_SCHEMA = {
9544
9580
  },
9545
9581
  // JourneyActionProperties: Discrete action at a journey step, classified by service layer.
9546
9582
  journey_action: {
9583
+ action_order: { type: "number", description: "Display order of this action within its step (0-indexed). The scalar ordering convention shared with `journey_phase.phase_order` and `journey_step.step_order` (UPG-663). Orders the service-blueprint rows within a single moment." },
9547
9584
  layer: { type: "string", enum: ["user", "frontstage", "backstage", "support"], description: "Service layer" },
9548
9585
  action_description: { type: "string", description: "Plain-language description. Primary content of the action." },
9549
9586
  channel: { type: "string", enum: ["in-app", "email", "web", "mobile", "phone", "in-person", "sms", "social", "other"], description: "Channel or surface. Keeps service-blueprint columns consistent across the journey." },
@@ -9575,7 +9612,7 @@ var UPG_PROPERTY_SCHEMA = {
9575
9612
  system: { type: "string", description: "Performing system or service" },
9576
9613
  notes: { type: "string", description: "Free-text notes, observations, or follow-up questions" }
9577
9614
  },
9578
- // JourneyPhaseProperties: Phase within a user journey. Groups journey steps into stages.
9615
+ // JourneyPhaseProperties: Phase within a user journey. A temporal BAND over the journey's step
9579
9616
  journey_phase: {
9580
9617
  phase_order: { type: "number", description: "Display order within the journey (0-indexed)" },
9581
9618
  label: { type: "string", description: 'Short human-readable name. @example "Discovery", "Onboarding", "Activation"' },
@@ -9586,8 +9623,9 @@ var UPG_PROPERTY_SCHEMA = {
9586
9623
  key_questions: { type: "string[]", description: "Open questions the user asks themselves. Fuel for design and content priorities." },
9587
9624
  timeframe: { type: "string", description: 'Typical time window. @example "first 30 seconds", "days 1\u20137", "onboarding week"' }
9588
9625
  },
9589
- // JourneyStepProperties: Single step within a user journey.
9626
+ // JourneyStepProperties: Single step within a user journey. A user-moment on the journey's single
9590
9627
  journey_step: {
9628
+ step_order: { type: "number", description: "Display order within the journey's step timeline (0-indexed). The scalar ordering convention shared with `journey_phase.phase_order` and `journey_action.action_order` (UPG-663). For branching journeys, the explicit `journey_step_precedes_journey_step` edge captures the chain." },
9591
9629
  touchpoint: { type: "string", description: "Interaction touchpoint" },
9592
9630
  channel: { type: "string", description: 'Channel (e.g. "web", "email", "in-store")' },
9593
9631
  emotion_score: {
@@ -17072,8 +17110,13 @@ var STANDARD_LABELS = {
17072
17110
  interview_guide: { alt_labels: ["discussion guide", "interview script", "moderator guide"] },
17073
17111
  survey_response: { alt_labels: ["survey answer", "questionnaire response"] },
17074
17112
  // Design layer
17075
- user_journey: { alt_labels: ["journey map", "customer journey", "experience map", "journey"] },
17076
- journey_step: { alt_labels: ["touchpoint", "journey stage", "journey phase"] },
17113
+ // (UPG-663) alt_labels must not collide with other entity types' canonical
17114
+ // names or alt_labels, or the string->type resolver is ambiguous.
17115
+ // Dropped 'customer journey' (collides with customer_journey_stage),
17116
+ // 'touchpoint' and 'journey phase' (both distinct entity surfaces: a
17117
+ // touchpoint is the journey_step.touchpoint property, a phase is journey_phase).
17118
+ user_journey: { alt_labels: ["journey map", "experience map", "journey"] },
17119
+ journey_step: { alt_labels: ["journey moment", "journey stage"] },
17077
17120
  design_component: { alt_labels: ["component", "ui component", "design element"] },
17078
17121
  design_token: { alt_labels: ["token", "style token", "css variable"] },
17079
17122
  wireframe: { alt_labels: ["wireflow", "lo-fi mockup", "skeleton"] },
@@ -19085,7 +19128,11 @@ var PRODUCT_SPEC_GUIDE = {
19085
19128
  var UX_DESIGN_GUIDE = {
19086
19129
  domain_id: "ux_design",
19087
19130
  anchor_entity: "user_journey",
19088
- creation_sequence: ["user_journey", "journey_phase", "journey_action", "journey_step", "screen", "screen_state", "user_flow", "wireframe", "prototype", "design_question", "design_concept"],
19131
+ // (UPG-663) creation order follows containment: a journey_action is a child
19132
+ // of a journey_step, so the step must precede the action. The journey is the
19133
+ // anchor; phases are the band overlay; steps are the timeline; actions
19134
+ // decompose a step into service-blueprint rows.
19135
+ creation_sequence: ["user_journey", "journey_phase", "journey_step", "journey_action", "screen", "screen_state", "user_flow", "wireframe", "prototype", "design_question", "design_concept"],
19089
19136
  patterns: [
19090
19137
  {
19091
19138
  name: "Journey to Screen Flow",
@@ -22357,6 +22404,40 @@ var UPG_ANTI_PATTERNS = [
22357
22404
  stages: ["concept", "validation", "build", "beta", "launch", "growth", "mature"],
22358
22405
  severity: "medium",
22359
22406
  source: { kind: "fundamental" }
22407
+ },
22408
+ // ── Experience-design layer ─────────────────────────────────────────────
22409
+ {
22410
+ id: "journey-phases-without-canonical-steps",
22411
+ name: "Journey phases without a step spine",
22412
+ description: 'The graph has journey phases spanning steps (`journey_phase_spans_journey_step`), but no journey owns its steps via `user_journey_contains_journey_step`. A phase is a band over a step timeline, not a container. When the timeline itself is missing there is no canonical answer to "what are the steps of this journey?". The phase overlay points at steps the journey does not own.',
22413
+ structured_condition: {
22414
+ operator: "and",
22415
+ checks: [
22416
+ {
22417
+ check: {
22418
+ type: "relationship",
22419
+ source_type: "journey_phase",
22420
+ edge_type: "journey_phase_spans_journey_step",
22421
+ target_type: "journey_step",
22422
+ comparison: "exists"
22423
+ }
22424
+ },
22425
+ {
22426
+ check: {
22427
+ type: "relationship",
22428
+ source_type: "user_journey",
22429
+ edge_type: "user_journey_contains_journey_step",
22430
+ target_type: "journey_step",
22431
+ comparison: "not_exists"
22432
+ }
22433
+ }
22434
+ ]
22435
+ },
22436
+ why_it_matters: "Steps owned by no journey render a different step list per consumer: the phase overlay sees them, a journey-direct walk does not. The journey has no deterministic step spine to traverse, score, or map to screens.",
22437
+ remediation: "Own every step under its journey with `user_journey_contains_journey_step`, then let phases span ranges of that single timeline via `journey_phase_spans_journey_step`. The phase is a non-owning band overlay, not the step container.",
22438
+ stages: ["concept", "validation", "build", "beta", "launch", "growth"],
22439
+ severity: "high",
22440
+ source: { kind: "fundamental" }
22360
22441
  }
22361
22442
  ];
22362
22443
  var SEVERITY_ORDER = {
@@ -24646,7 +24727,7 @@ function serializePortfolioWithHeader(doc, opts) {
24646
24727
  header.integrity = { algorithm: INTEGRITY_ALGORITHM, body: computeBodyChecksum(doc) };
24647
24728
  return JSON.stringify({ $upg: header, ...body }, null, 2) + "\n";
24648
24729
  }
24649
- var UPG_VERSION = "0.9.0";
24730
+ var UPG_VERSION = "0.9.2";
24650
24731
  var MARKDOWN_FORMAT_VERSION = "0.1";
24651
24732
  var UPG_TYPES = getTypes();
24652
24733
  var UPG_TYPES_SET = new Set(UPG_TYPES);
@@ -26849,6 +26930,38 @@ import {
26849
26930
  WorkspaceAlreadyExistsError,
26850
26931
  WorkspaceNotInitialisedError
26851
26932
  } from "@unified-product-graph/sdk";
26933
+ function isExistingFile(p) {
26934
+ try {
26935
+ return fs.statSync(p).isFile();
26936
+ } catch {
26937
+ return false;
26938
+ }
26939
+ }
26940
+ function findWorkspaceUpgFiles(cwd) {
26941
+ const candidates = [];
26942
+ let topEntries;
26943
+ try {
26944
+ topEntries = fs.readdirSync(cwd, { withFileTypes: true });
26945
+ } catch {
26946
+ return candidates;
26947
+ }
26948
+ for (const entry of topEntries) {
26949
+ if (entry.isFile() && entry.name.endsWith(".upg")) {
26950
+ candidates.push(path3.join(cwd, entry.name));
26951
+ } else if (entry.isDirectory() && (entry.name === ".upg" || !entry.name.startsWith("."))) {
26952
+ try {
26953
+ const subEntries = fs.readdirSync(path3.join(cwd, entry.name), { withFileTypes: true });
26954
+ for (const sub of subEntries) {
26955
+ if (sub.isFile() && sub.name.endsWith(".upg")) {
26956
+ candidates.push(path3.join(cwd, entry.name, sub.name));
26957
+ }
26958
+ }
26959
+ } catch {
26960
+ }
26961
+ }
26962
+ }
26963
+ return candidates;
26964
+ }
26852
26965
  var listLocalProducts = (_args, _ctx) => {
26853
26966
  const cwd = process.cwd();
26854
26967
  const products = [];
@@ -26871,26 +26984,7 @@ var listLocalProducts = (_args, _ctx) => {
26871
26984
  }
26872
26985
  } catch {
26873
26986
  }
26874
- const candidates = [];
26875
- const topEntries = fs.readdirSync(cwd, { withFileTypes: true });
26876
- for (const entry of topEntries) {
26877
- if (entry.isFile() && entry.name.endsWith(".upg")) {
26878
- candidates.push(path3.join(cwd, entry.name));
26879
- } else if (entry.isDirectory() && (entry.name === ".upg" || !entry.name.startsWith("."))) {
26880
- try {
26881
- const subEntries = fs.readdirSync(
26882
- path3.join(cwd, entry.name),
26883
- { withFileTypes: true }
26884
- );
26885
- for (const sub of subEntries) {
26886
- if (sub.isFile() && sub.name.endsWith(".upg")) {
26887
- candidates.push(path3.join(cwd, entry.name, sub.name));
26888
- }
26889
- }
26890
- } catch {
26891
- }
26892
- }
26893
- }
26987
+ const candidates = findWorkspaceUpgFiles(cwd);
26894
26988
  for (const filePath of candidates) {
26895
26989
  try {
26896
26990
  const raw = fs.readFileSync(filePath, "utf-8");
@@ -26920,21 +27014,19 @@ var switchProduct = async (args, ctx) => {
26920
27014
  if (typeof fileArg !== "string" || fileArg.length === 0) {
26921
27015
  return textError("Missing required parameter: file (alias: product). Pass a .upg path or a bare product name.");
26922
27016
  }
26923
- let resolved = path3.resolve(fileArg);
26924
- if (!fs.existsSync(resolved)) {
26925
- const cwd = process.cwd();
26926
- const workspaceCandidates = [
26927
- path3.join(cwd, ".upg", fileArg),
26928
- path3.join(cwd, ".upg", fileArg + ".upg")
26929
- ];
26930
- const found = workspaceCandidates.find((c) => fs.existsSync(c));
26931
- if (found) {
26932
- resolved = found;
26933
- } else {
26934
- return textError(
26935
- `File not found: ${resolved} (also checked .upg/${fileArg} and .upg/${fileArg}.upg)`
26936
- );
26937
- }
27017
+ const cwd = process.cwd();
27018
+ const direct = path3.resolve(fileArg);
27019
+ const candidates = [
27020
+ path3.join(cwd, ".upg", fileArg),
27021
+ path3.join(cwd, ".upg", `${fileArg}.upg`),
27022
+ direct,
27023
+ `${direct}.upg`
27024
+ ];
27025
+ const resolved = candidates.find(isExistingFile);
27026
+ if (!resolved) {
27027
+ return textError(
27028
+ `File not found: ${direct} (also checked .upg/${fileArg} and .upg/${fileArg}.upg). Pass a .upg path or a bare product name from list_local_products.`
27029
+ );
26938
27030
  }
26939
27031
  try {
26940
27032
  await store.flush();
@@ -27419,6 +27511,312 @@ var batchCreateCrossProductEdges = async (args, _ctx) => {
27419
27511
  );
27420
27512
  };
27421
27513
 
27514
+ // src/tools/portfolio-read.ts
27515
+ import * as path4 from "path";
27516
+ import * as fs2 from "fs";
27517
+ import { UPGFileStore, computeGraphDigest as computeGraphDigest2 } from "@unified-product-graph/sdk";
27518
+
27519
+ // src/lib/graph-traverse.ts
27520
+ function traverseGraph(reader, params) {
27521
+ const fromType = params.from;
27522
+ const fromId = params.from_id;
27523
+ if (!fromType && !fromId) {
27524
+ return { ok: false, error: 'Provide either "from" (entity type) or "from_id" (node ID)' };
27525
+ }
27526
+ const traverseEdgeTypes = params.traverse;
27527
+ const maxDepth = Math.min(Math.max(params.depth ?? 3, 1), 10);
27528
+ const maxNodes = Math.min(Math.max(params.limit ?? 200, 1), 1e3);
27529
+ const includeFields = new Set(params.include ?? ["title", "status", "type"]);
27530
+ includeFields.add("id");
27531
+ includeFields.add("type");
27532
+ let startNodes;
27533
+ if (fromId) {
27534
+ const node = reader.getNode(fromId);
27535
+ if (!node) return { ok: false, error: `Node not found: ${fromId}` };
27536
+ startNodes = [node];
27537
+ } else {
27538
+ startNodes = reader.getAllNodes().filter((n) => n.type === fromType);
27539
+ }
27540
+ if (startNodes.length === 0) {
27541
+ return { ok: true, result: { nodes: [], edges: [], total_nodes: 0, total_edges: 0, truncated: false } };
27542
+ }
27543
+ const visited = /* @__PURE__ */ new Set();
27544
+ const collectedNodes = [];
27545
+ const collectedEdges = /* @__PURE__ */ new Map();
27546
+ const queue = [];
27547
+ let truncated = false;
27548
+ let maxDepthReached = 0;
27549
+ for (const n of startNodes) {
27550
+ if (collectedNodes.length >= maxNodes) {
27551
+ truncated = true;
27552
+ break;
27553
+ }
27554
+ visited.add(n.id);
27555
+ collectedNodes.push(n);
27556
+ queue.push({ id: n.id, level: 0 });
27557
+ }
27558
+ while (queue.length > 0) {
27559
+ if (collectedNodes.length >= maxNodes) {
27560
+ truncated = true;
27561
+ break;
27562
+ }
27563
+ const { id, level } = queue.shift();
27564
+ if (level > maxDepthReached) maxDepthReached = level;
27565
+ if (level >= maxDepth) continue;
27566
+ const edges = reader.getEdgesForNode(id);
27567
+ for (const edge of edges) {
27568
+ if (edge.source !== id) continue;
27569
+ if (traverseEdgeTypes && traverseEdgeTypes.length > 0) {
27570
+ const edgeTypeForLevel = level < traverseEdgeTypes.length ? traverseEdgeTypes[level] : traverseEdgeTypes[traverseEdgeTypes.length - 1];
27571
+ if (edgeTypeForLevel.startsWith("!")) {
27572
+ if (edge.type === edgeTypeForLevel.slice(1)) continue;
27573
+ } else {
27574
+ if (edge.type !== edgeTypeForLevel) continue;
27575
+ }
27576
+ }
27577
+ collectedEdges.set(edge.id, edge);
27578
+ const neighborId = edge.target;
27579
+ if (!visited.has(neighborId)) {
27580
+ visited.add(neighborId);
27581
+ const neighbor = reader.getNode(neighborId);
27582
+ if (neighbor) {
27583
+ if (collectedNodes.length >= maxNodes) {
27584
+ truncated = true;
27585
+ break;
27586
+ }
27587
+ collectedNodes.push(neighbor);
27588
+ queue.push({ id: neighborId, level: level + 1 });
27589
+ }
27590
+ }
27591
+ }
27592
+ }
27593
+ const propInclude = params.property_include;
27594
+ const propFilter = propInclude && propInclude.length > 0 ? new Set(propInclude) : null;
27595
+ const projectedNodes = collectedNodes.map((n) => {
27596
+ const projected = { id: n.id, type: n.type };
27597
+ if (includeFields.has("title")) projected.title = n.title;
27598
+ if (includeFields.has("status")) projected.status = n.status;
27599
+ if (includeFields.has("tags")) projected.tags = n.tags;
27600
+ if (includeFields.has("description")) projected.description = n.description;
27601
+ if (includeFields.has("properties")) {
27602
+ if (propFilter && n.properties) {
27603
+ const filtered = {};
27604
+ for (const key of propFilter) {
27605
+ if (key in n.properties) filtered[key] = n.properties[key];
27606
+ }
27607
+ projected.properties = filtered;
27608
+ } else {
27609
+ projected.properties = n.properties;
27610
+ }
27611
+ }
27612
+ return projected;
27613
+ });
27614
+ const edgeInclude = params.edge_include;
27615
+ let edgeArray;
27616
+ if (edgeInclude !== void 0 && edgeInclude.length === 0) {
27617
+ edgeArray = [];
27618
+ } else {
27619
+ const edgeFields = edgeInclude ? new Set(edgeInclude) : null;
27620
+ edgeArray = [...collectedEdges.values()].map((e) => {
27621
+ if (!edgeFields) return { id: e.id, type: e.type, source: e.source, target: e.target };
27622
+ const projected = {};
27623
+ if (edgeFields.has("id")) projected.id = e.id;
27624
+ if (edgeFields.has("type")) projected.type = e.type;
27625
+ if (edgeFields.has("source")) projected.source = e.source;
27626
+ if (edgeFields.has("target")) projected.target = e.target;
27627
+ return projected;
27628
+ });
27629
+ }
27630
+ const result = {
27631
+ nodes: projectedNodes,
27632
+ edges: edgeArray,
27633
+ total_nodes: projectedNodes.length,
27634
+ total_edges: edgeArray.length,
27635
+ truncated
27636
+ };
27637
+ if (truncated) result.truncated_at_depth = maxDepthReached;
27638
+ return { ok: true, result };
27639
+ }
27640
+
27641
+ // src/tools/portfolio-read.ts
27642
+ function resolveScopedProducts(cwd, scope) {
27643
+ const all = [];
27644
+ for (const absPath of findWorkspaceUpgFiles(cwd)) {
27645
+ try {
27646
+ const doc = JSON.parse(fs2.readFileSync(absPath, "utf-8"));
27647
+ if (!doc.product) continue;
27648
+ all.push({
27649
+ id: doc.product.id ?? null,
27650
+ title: doc.product.title ?? "(untitled)",
27651
+ file: path4.relative(cwd, absPath),
27652
+ absPath
27653
+ });
27654
+ } catch {
27655
+ }
27656
+ }
27657
+ if (!scope || scope.length === 0) {
27658
+ return { products: all, unmatched: [] };
27659
+ }
27660
+ const matches = (p, want) => p.id === want || p.file === want || path4.basename(p.file) === want || path4.basename(p.file, ".upg") === want;
27661
+ const products = all.filter((p) => scope.some((want) => matches(p, want)));
27662
+ const unmatched = scope.filter((want) => !all.some((p) => matches(p, want)));
27663
+ return { products, unmatched };
27664
+ }
27665
+ async function readerFor(product, activeStore) {
27666
+ const activePath = activeStore.getFilePath();
27667
+ if (activePath && path4.resolve(activePath) === path4.resolve(product.absPath)) {
27668
+ return { reader: activeStore, store: activeStore, active: true };
27669
+ }
27670
+ const store = new UPGFileStore();
27671
+ await store.loadReadOnly(product.absPath);
27672
+ return { reader: store, store, active: false };
27673
+ }
27674
+ var portfolioQuery = async (args, ctx) => {
27675
+ const { store } = ctx;
27676
+ const from = args.from;
27677
+ const fromId = args.from_id;
27678
+ if (!from && !fromId) {
27679
+ return textError('Provide either "from" (entity type) or "from_id" (node ID)');
27680
+ }
27681
+ const scope = args.scope;
27682
+ const cwd = process.cwd();
27683
+ const { products, unmatched } = resolveScopedProducts(cwd, scope);
27684
+ if (products.length === 0) {
27685
+ return text(
27686
+ JSON.stringify(
27687
+ {
27688
+ products: [],
27689
+ products_searched: 0,
27690
+ products_with_matches: 0,
27691
+ empty_products: [],
27692
+ ...unmatched.length > 0 ? { unmatched_scope: unmatched } : {},
27693
+ note: scope && scope.length > 0 ? "No workspace products matched the requested scope." : "No products found in the workspace. Run from a directory with a .upg/ workspace."
27694
+ },
27695
+ null,
27696
+ 2
27697
+ )
27698
+ );
27699
+ }
27700
+ const perProductLimit = Math.min(Math.max(args.limit ?? 100, 1), 1e3);
27701
+ const params = {
27702
+ from,
27703
+ from_id: fromId,
27704
+ traverse: args.traverse,
27705
+ depth: args.depth,
27706
+ limit: perProductLimit,
27707
+ include: args.include,
27708
+ edge_include: args.edge_include,
27709
+ property_include: args.property_include
27710
+ };
27711
+ const matched = [];
27712
+ const emptyProducts = [];
27713
+ const errored = [];
27714
+ let totalNodes = 0;
27715
+ let totalEdges = 0;
27716
+ for (const product of products) {
27717
+ let reader;
27718
+ try {
27719
+ ;
27720
+ ({ reader } = await readerFor(product, store));
27721
+ } catch (err) {
27722
+ errored.push({ product_id: product.id, file: product.file, error: err.message });
27723
+ continue;
27724
+ }
27725
+ const outcome = traverseGraph(reader, params);
27726
+ if (!outcome.ok) {
27727
+ emptyProducts.push(product.id ?? product.file);
27728
+ continue;
27729
+ }
27730
+ const r = outcome.result;
27731
+ if (r.total_nodes === 0) {
27732
+ emptyProducts.push(product.id ?? product.file);
27733
+ continue;
27734
+ }
27735
+ totalNodes += r.total_nodes;
27736
+ totalEdges += r.total_edges;
27737
+ matched.push({
27738
+ product_id: product.id,
27739
+ file: product.file,
27740
+ title: product.title,
27741
+ total_nodes: r.total_nodes,
27742
+ total_edges: r.total_edges,
27743
+ nodes: r.nodes,
27744
+ edges: r.edges,
27745
+ ...r.truncated ? { truncated: true, truncated_at_depth: r.truncated_at_depth } : {}
27746
+ });
27747
+ }
27748
+ const guard = preflightPayload({
27749
+ toolName: "portfolio_query",
27750
+ nodeCount: totalNodes,
27751
+ edgeCount: totalEdges,
27752
+ compactEdges: true,
27753
+ argsHint: `from=${from ?? fromId}, products=${matched.length}, limit=${perProductLimit}`
27754
+ });
27755
+ if (guard.kind === "refuse") return guard.result;
27756
+ const response = {
27757
+ products: matched,
27758
+ products_searched: products.length,
27759
+ products_with_matches: matched.length,
27760
+ total_nodes: totalNodes,
27761
+ total_edges: totalEdges,
27762
+ empty_products: emptyProducts
27763
+ };
27764
+ if (errored.length > 0) response.errored_products = errored;
27765
+ if (unmatched.length > 0) response.unmatched_scope = unmatched;
27766
+ if (guard.kind === "warn") Object.assign(response, guard.fields);
27767
+ return text(JSON.stringify(response, null, 2));
27768
+ };
27769
+ var portfolioDigest = async (args, ctx) => {
27770
+ const { store } = ctx;
27771
+ const scope = args.scope;
27772
+ const cwd = process.cwd();
27773
+ const { products, unmatched } = resolveScopedProducts(cwd, scope);
27774
+ const summaries = [];
27775
+ const errored = [];
27776
+ const byStage = {};
27777
+ let totalNodes = 0;
27778
+ let totalEdges = 0;
27779
+ for (const product of products) {
27780
+ try {
27781
+ const { store: reader } = await readerFor(product, store);
27782
+ const digest = computeGraphDigest2(reader);
27783
+ const stage = digest.product.stage || "unset";
27784
+ byStage[stage] = (byStage[stage] ?? 0) + 1;
27785
+ totalNodes += digest.counts.total_nodes;
27786
+ totalEdges += digest.counts.total_edges;
27787
+ const topTypes = Object.entries(digest.counts.by_type).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([type, count]) => ({ type, count }));
27788
+ summaries.push({
27789
+ product_id: product.id,
27790
+ file: product.file,
27791
+ title: digest.product.title,
27792
+ stage: digest.product.stage || null,
27793
+ total_nodes: digest.counts.total_nodes,
27794
+ total_edges: digest.counts.total_edges,
27795
+ health: digest.health,
27796
+ coverage_pct: digest.coverage.stage_summary?.overall_pct ?? null,
27797
+ top_types: topTypes
27798
+ });
27799
+ } catch (err) {
27800
+ errored.push({ product_id: product.id, file: product.file, error: err.message });
27801
+ }
27802
+ }
27803
+ const response = {
27804
+ products: summaries,
27805
+ rollup: {
27806
+ products: summaries.length,
27807
+ total_nodes: totalNodes,
27808
+ total_edges: totalEdges,
27809
+ by_stage: byStage
27810
+ }
27811
+ };
27812
+ if (errored.length > 0) response.errored_products = errored;
27813
+ if (unmatched.length > 0) response.unmatched_scope = unmatched;
27814
+ if (products.length === 0) {
27815
+ response.note = scope && scope.length > 0 ? "No workspace products matched the requested scope." : "No products found in the workspace. Run from a directory with a .upg/ workspace.";
27816
+ }
27817
+ return text(JSON.stringify(response, null, 2));
27818
+ };
27819
+
27422
27820
  // src/tools/schema.ts
27423
27821
  var getEntitySchema = (args, _ctx) => {
27424
27822
  const rawType = args.type;
@@ -28120,24 +28518,24 @@ var prioritise = (args, ctx) => {
28120
28518
  };
28121
28519
  var trace = (args, ctx) => {
28122
28520
  const anchor = args.anchor;
28123
- const path7 = args.path;
28521
+ const path8 = args.path;
28124
28522
  const edgesOverride = args.edges_override;
28125
28523
  if (!anchor) {
28126
28524
  return textError("Missing required parameter: anchor (entity_id)");
28127
28525
  }
28128
- if (!path7 || !Array.isArray(path7) || path7.length === 0) {
28526
+ if (!path8 || !Array.isArray(path8) || path8.length === 0) {
28129
28527
  return textError("Missing required parameter: path (UPGEntityType[])");
28130
28528
  }
28131
- if (edgesOverride && edgesOverride.length !== path7.length) {
28529
+ if (edgesOverride && edgesOverride.length !== path8.length) {
28132
28530
  return textError(
28133
- `edges_override length (${edgesOverride.length}) must match path length (${path7.length})`
28531
+ `edges_override length (${edgesOverride.length}) must match path length (${path8.length})`
28134
28532
  );
28135
28533
  }
28136
- const result = executeTrace(ctx.store, anchor, path7, edgesOverride);
28534
+ const result = executeTrace(ctx.store, anchor, path8, edgesOverride);
28137
28535
  const payload = {
28138
28536
  params: {
28139
28537
  anchor,
28140
- path: path7,
28538
+ path: path8,
28141
28539
  edges_override: edgesOverride ?? null
28142
28540
  },
28143
28541
  trail: result.trail,
@@ -28733,7 +29131,7 @@ var migrateStatus = (args, ctx) => {
28733
29131
 
28734
29132
  // src/tools/sync.ts
28735
29133
  import * as fsp4 from "fs/promises";
28736
- import * as path4 from "path";
29134
+ import * as path5 from "path";
28737
29135
  import { nodeId, edgeId as edgeId4 } from "@unified-product-graph/sdk";
28738
29136
  var getSyncState = async (_args, ctx) => {
28739
29137
  const { store, sync } = ctx;
@@ -28901,7 +29299,7 @@ var pushToCloud = async (args, ctx) => {
28901
29299
  const productId = args.product_id;
28902
29300
  if (!cloudEndpoint || !apiKey) {
28903
29301
  try {
28904
- const mcpConfigPath = path4.join(process.cwd(), ".mcp.json");
29302
+ const mcpConfigPath = path5.join(process.cwd(), ".mcp.json");
28905
29303
  const mcpRaw = await fsp4.readFile(mcpConfigPath, "utf-8");
28906
29304
  const mcpConfig = JSON.parse(mcpRaw);
28907
29305
  const upgCloud = mcpConfig.mcpServers?.["upg-cloud"];
@@ -28986,8 +29384,8 @@ var pushToCloud = async (args, ctx) => {
28986
29384
  };
28987
29385
 
28988
29386
  // src/tools/skills.ts
28989
- import { existsSync as existsSync2, lstatSync, readlinkSync, readFileSync as readFileSync2, readdirSync as readdirSync2, realpathSync } from "fs";
28990
- import { join as join5, resolve as resolve2, dirname as dirname3 } from "path";
29387
+ import { existsSync as existsSync2, lstatSync, readlinkSync, readFileSync as readFileSync3, readdirSync as readdirSync2, realpathSync } from "fs";
29388
+ import { join as join5, resolve as resolve3, dirname as dirname3 } from "path";
28991
29389
  import { fileURLToPath } from "url";
28992
29390
  function repoRoot() {
28993
29391
  return process.cwd();
@@ -29016,7 +29414,7 @@ function resolveBundledSkillsDir() {
29016
29414
  } catch {
29017
29415
  md = process.cwd();
29018
29416
  }
29019
- for (const c of [resolve2(md, "..", "skills"), resolve2(md, "..", "..", "skills"), resolve2(md, "skills")]) {
29417
+ for (const c of [resolve3(md, "..", "skills"), resolve3(md, "..", "..", "skills"), resolve3(md, "skills")]) {
29020
29418
  if (isSkillsDir(c)) return c;
29021
29419
  }
29022
29420
  let dir = md;
@@ -29030,12 +29428,12 @@ function resolveBundledSkillsDir() {
29030
29428
  return null;
29031
29429
  }
29032
29430
  function sourceSkillsDir() {
29033
- const cwdPath = resolve2(repoRoot(), "packages/upg-mcp-server/skills");
29431
+ const cwdPath = resolve3(repoRoot(), "packages/upg-mcp-server/skills");
29034
29432
  if (existsSync2(cwdPath)) return cwdPath;
29035
29433
  return resolveBundledSkillsDir() ?? cwdPath;
29036
29434
  }
29037
29435
  function deployedSkillsDir() {
29038
- return resolve2(repoRoot(), ".claude/skills");
29436
+ return resolve3(repoRoot(), ".claude/skills");
29039
29437
  }
29040
29438
  function parseFrontmatter(body) {
29041
29439
  if (!body.startsWith("---\n")) return null;
@@ -29079,11 +29477,11 @@ function auditOne(name) {
29079
29477
  let deployedFrontmatter = null;
29080
29478
  let deployedFirstHeading = null;
29081
29479
  if (deployedExists) {
29082
- const deployedBody = readFileSync2(deployedPath, "utf8");
29480
+ const deployedBody = readFileSync3(deployedPath, "utf8");
29083
29481
  deployedFrontmatter = parseFrontmatter(deployedBody);
29084
29482
  deployedFirstHeading = firstHeading(deployedBody);
29085
29483
  if (sourceExists) {
29086
- const sourceBody = readFileSync2(sourcePath, "utf8");
29484
+ const sourceBody = readFileSync3(sourcePath, "utf8");
29087
29485
  inSync = deployedBody === sourceBody;
29088
29486
  if (!inSync) {
29089
29487
  issues.push("Deployed SKILL.md differs from canonical source; symlink is stale or broken");
@@ -30044,7 +30442,7 @@ var TOOL_DEFINITIONS = [
30044
30442
  },
30045
30443
  {
30046
30444
  name: "list_cross_edge_types",
30047
- description: "List the canonical cross-product edge types from `UPG_CROSS_EDGE_TYPES`: `shares_persona`, `shares_competitor`, `shares_metric`, `depends_on_product`, `cannibalises`, `succeeds`, `hosts`. Portfolio-level relationships across products. Distinct from the within-product `UPG_EDGE_CATALOG`.",
30445
+ description: "List the canonical cross-product edge types from `UPG_CROSS_EDGE_TYPES`: `shares_persona`, `shares_competitor`, `shares_metric`, `depends_on_product`, `cannibalises`, `succeeds`, `hosts`, `contributes_to`. Portfolio-level relationships across products. Distinct from the within-product `UPG_EDGE_CATALOG`.",
30048
30446
  inputSchema: { type: "object", properties: {} }
30049
30447
  },
30050
30448
  {
@@ -30525,7 +30923,7 @@ var TOOL_DEFINITIONS = [
30525
30923
  },
30526
30924
  {
30527
30925
  name: "create_cross_product_edge",
30528
- description: "Create a cross-product relationship between two entities in different products within a portfolio graph. Types: `shares_persona`, `shares_competitor`, `shares_metric`, `depends_on_product`, `cannibalises`, `succeeds`, `hosts` (host product runs the hosted product inside itself, directed host to hosted).",
30926
+ description: "Create a cross-product relationship between two entities in different products within a portfolio graph. Types: `shares_persona`, `shares_competitor`, `shares_metric`, `depends_on_product`, `cannibalises`, `succeeds`, `hosts` (host product runs the hosted product inside itself, directed host to hosted), `contributes_to` (a product strategy entity rolls up to a higher-level one, e.g. product objective \u2192 company objective, product key_result \u2192 company key_result; directed subordinate to superior).",
30529
30927
  inputSchema: {
30530
30928
  type: "object",
30531
30929
  properties: {
@@ -30533,7 +30931,7 @@ var TOOL_DEFINITIONS = [
30533
30931
  target_id: { type: "string", description: "Target node ID" },
30534
30932
  type: {
30535
30933
  type: "string",
30536
- enum: ["shares_persona", "shares_competitor", "shares_metric", "depends_on_product", "cannibalises", "succeeds", "hosts"],
30934
+ enum: ["shares_persona", "shares_competitor", "shares_metric", "depends_on_product", "cannibalises", "succeeds", "hosts", "contributes_to"],
30537
30935
  description: "Cross-product relationship type"
30538
30936
  },
30539
30937
  source_product_id: { type: "string", description: "Product ID of the source node" },
@@ -30569,7 +30967,7 @@ var TOOL_DEFINITIONS = [
30569
30967
  target_id: { type: "string", description: "Target node ID (bare or qualified {product_id}/{node_id})" },
30570
30968
  type: {
30571
30969
  type: "string",
30572
- enum: ["shares_persona", "shares_competitor", "shares_metric", "depends_on_product", "cannibalises", "succeeds", "hosts"],
30970
+ enum: ["shares_persona", "shares_competitor", "shares_metric", "depends_on_product", "cannibalises", "succeeds", "hosts", "contributes_to"],
30573
30971
  description: "Cross-product relationship type"
30574
30972
  },
30575
30973
  source_product_id: { type: "string", description: "Product ID of the source node (qualifies a bare source_id)" },
@@ -30588,6 +30986,58 @@ var TOOL_DEFINITIONS = [
30588
30986
  description: "List all cross-product edges stored in the portfolio document (`.upg/portfolio.upg`). Empty list when the portfolio document is absent.",
30589
30987
  inputSchema: { type: "object", properties: {} }
30590
30988
  },
30989
+ {
30990
+ name: "portfolio_query",
30991
+ description: 'Traverse the graph ACROSS products in one call (the multi-product `query`). Runs the same BFS (typed-edge traversal + field projection) against every product in scope and tags each subgraph with its source `product_id`, without `switch_product` (the active product is read live; others are read-only). Use for portfolio-level questions ("every product\'s strategy region", "which products have a persona"). `from_id` only matches in its owning product. Read-only.',
30992
+ inputSchema: {
30993
+ type: "object",
30994
+ properties: {
30995
+ from: { type: "string", description: "Start from all nodes of this type (in each product)" },
30996
+ from_id: { type: "string", description: "Start from a specific node ID. Node IDs are product-local; only the owning product returns results." },
30997
+ traverse: {
30998
+ type: "array",
30999
+ items: { type: "string" },
31000
+ description: "Edge types to follow at each level (in order). If omitted, follows all edges. Prefix with ! to exclude."
31001
+ },
31002
+ depth: { type: "number", description: "Max traversal depth (default 3, max 10)" },
31003
+ include: {
31004
+ type: "array",
31005
+ items: { type: "string" },
31006
+ description: 'Fields per node: "title", "status", "tags", "description", "properties" (default: title, status, type)'
31007
+ },
31008
+ limit: { type: "number", description: "Max nodes per product (default 100, max 1000)" },
31009
+ edge_include: {
31010
+ type: "array",
31011
+ items: { type: "string" },
31012
+ description: 'Edge fields to return: "id", "type", "source", "target". Empty array = no edges. Default: all fields.'
31013
+ },
31014
+ property_include: {
31015
+ type: "array",
31016
+ items: { type: "string" },
31017
+ description: 'When "properties" is in include, only return these property keys.'
31018
+ },
31019
+ scope: {
31020
+ type: "array",
31021
+ items: { type: "string" },
31022
+ description: "Product IDs (or files) to query. Omit to query ALL products in the workspace. Match by product id, relative file, or basename."
31023
+ }
31024
+ }
31025
+ }
31026
+ },
31027
+ {
31028
+ name: "portfolio_digest",
31029
+ description: "Roll up every product's counts, health, and stage-coverage in one call (the multi-product `get_graph_digest`). The strategic-surface read that otherwise required `switch_product` + `get_graph_digest` per graph. Returns per-product summaries plus a portfolio rollup (totals, products-by-stage). Read-only; never mutates active-product state.",
31030
+ inputSchema: {
31031
+ type: "object",
31032
+ properties: {
31033
+ scope: {
31034
+ type: "array",
31035
+ items: { type: "string" },
31036
+ description: "Product IDs (or files) to summarise. Omit to summarise ALL products in the workspace."
31037
+ }
31038
+ }
31039
+ }
31040
+ },
30591
31041
  {
30592
31042
  name: "migrate_cross_edges",
30593
31043
  description: "Migrate inline cross-product edges from the current product's `edges[]` into the portfolio document (`.upg/portfolio.upg`) with qualified IDs. `dry_run: true` (default) previews; `dry_run: false` applies. Requires `source_product_id` to qualify source node IDs.",
@@ -30774,6 +31224,8 @@ var HANDLERS = {
30774
31224
  attach_product_to_portfolio: attachProductToPortfolioTool,
30775
31225
  detach_product_from_portfolio: detachProductFromPortfolioTool,
30776
31226
  list_portfolio_cross_edges: listPortfolioCrossEdges,
31227
+ portfolio_query: portfolioQuery,
31228
+ portfolio_digest: portfolioDigest,
30777
31229
  migrate_cross_edges: migrateCrossEdges,
30778
31230
  get_sync_state: getSyncState,
30779
31231
  apply_pull_changeset: applyPullChangeset,
@@ -30832,9 +31284,9 @@ var SERVER_INSTRUCTIONS = [
30832
31284
  ].join("\n");
30833
31285
  function resolvePackageVersion() {
30834
31286
  try {
30835
- const here = path5.dirname(fileURLToPath2(import.meta.url));
30836
- const pkgPath = path5.resolve(here, "..", "package.json");
30837
- const raw = fs2.readFileSync(pkgPath, "utf-8");
31287
+ const here = path6.dirname(fileURLToPath2(import.meta.url));
31288
+ const pkgPath = path6.resolve(here, "..", "package.json");
31289
+ const raw = fs3.readFileSync(pkgPath, "utf-8");
30838
31290
  const pkg = JSON.parse(raw);
30839
31291
  if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version;
30840
31292
  } catch {
@@ -30876,7 +31328,7 @@ function createServer(store) {
30876
31328
  const result = handler ? await handler(args, ctx) : textError(`Unknown tool: ${name}`);
30877
31329
  if (logFile) {
30878
31330
  const entry = JSON.stringify({ ts: t0, tool: name, params: args, result, durationMs: Date.now() - t0 });
30879
- fs2.appendFileSync(logFile, entry + "\n");
31331
+ fs3.appendFileSync(logFile, entry + "\n");
30880
31332
  }
30881
31333
  return result;
30882
31334
  });
@@ -30893,15 +31345,15 @@ import { nanoid } from "nanoid";
30893
31345
  import { fileURLToPath as fileURLToPath3 } from "url";
30894
31346
  import { realpathSync as realpathSync2 } from "fs";
30895
31347
  async function discoverUPGFile(explicitFile) {
30896
- if (explicitFile) return path6.resolve(explicitFile);
31348
+ if (explicitFile) return path7.resolve(explicitFile);
30897
31349
  const cwd = process.cwd();
30898
- const workspacePath = path6.join(cwd, ".upg", "workspace.json");
31350
+ const workspacePath = path7.join(cwd, ".upg", "workspace.json");
30899
31351
  try {
30900
- const raw = await fs3.readFile(workspacePath, "utf-8");
31352
+ const raw = await fs4.readFile(workspacePath, "utf-8");
30901
31353
  const workspace = JSON.parse(raw);
30902
31354
  if (workspace.default_product) {
30903
- const filePath = path6.join(cwd, ".upg", workspace.default_product);
30904
- await fs3.access(filePath);
31355
+ const filePath = path7.join(cwd, ".upg", workspace.default_product);
31356
+ await fs4.access(filePath);
30905
31357
  const title = workspace.products?.find(
30906
31358
  (p) => p.file === workspace.default_product
30907
31359
  )?.title ?? workspace.default_product;
@@ -30912,16 +31364,16 @@ async function discoverUPGFile(explicitFile) {
30912
31364
  return filePath;
30913
31365
  }
30914
31366
  } catch {
30915
- const upgDir = path6.join(cwd, ".upg");
31367
+ const upgDir = path7.join(cwd, ".upg");
30916
31368
  try {
30917
- const dirEntries = await fs3.readdir(upgDir);
31369
+ const dirEntries = await fs4.readdir(upgDir);
30918
31370
  const upgFiles = dirEntries.filter((f) => f.endsWith(".upg")).sort();
30919
31371
  if (upgFiles.length > 0) {
30920
31372
  const products = [];
30921
31373
  for (const file of upgFiles) {
30922
- let title = path6.basename(file, ".upg");
31374
+ let title = path7.basename(file, ".upg");
30923
31375
  try {
30924
- const raw = await fs3.readFile(path6.join(upgDir, file), "utf-8");
31376
+ const raw = await fs4.readFile(path7.join(upgDir, file), "utf-8");
30925
31377
  const doc = JSON.parse(raw);
30926
31378
  if (doc.product?.title) title = doc.product.title;
30927
31379
  } catch {
@@ -30933,12 +31385,12 @@ async function discoverUPGFile(explicitFile) {
30933
31385
  default_product: upgFiles[0],
30934
31386
  products
30935
31387
  };
30936
- await fs3.writeFile(workspacePath, JSON.stringify(workspace, null, 2) + "\n", "utf-8");
31388
+ await fs4.writeFile(workspacePath, JSON.stringify(workspace, null, 2) + "\n", "utf-8");
30937
31389
  process.stderr.write(
30938
31390
  `UPG workspace: auto-created workspace.json (${upgFiles.length} product${upgFiles.length > 1 ? "s" : ""})
30939
31391
  `
30940
31392
  );
30941
- const filePath = path6.join(upgDir, upgFiles[0]);
31393
+ const filePath = path7.join(upgDir, upgFiles[0]);
30942
31394
  process.stderr.write(`UPG workspace: loading "${products[0].title}"
30943
31395
  `);
30944
31396
  return filePath;
@@ -30947,17 +31399,17 @@ async function discoverUPGFile(explicitFile) {
30947
31399
  }
30948
31400
  }
30949
31401
  try {
30950
- const entries = await fs3.readdir(cwd);
31402
+ const entries = await fs4.readdir(cwd);
30951
31403
  const upgFiles = entries.filter((f) => f.endsWith(".upg")).sort();
30952
31404
  if (upgFiles.length === 1) {
30953
- return path6.resolve(upgFiles[0]);
31405
+ return path7.resolve(upgFiles[0]);
30954
31406
  }
30955
31407
  if (upgFiles.length > 1) {
30956
31408
  process.stderr.write(
30957
31409
  `Found ${upgFiles.length} .upg files: loading ${upgFiles[0]}. Use --file to pick a specific one.
30958
31410
  `
30959
31411
  );
30960
- return path6.resolve(upgFiles[0]);
31412
+ return path7.resolve(upgFiles[0]);
30961
31413
  }
30962
31414
  } catch {
30963
31415
  }
@@ -30977,7 +31429,7 @@ async function runMcpServer() {
30977
31429
  });
30978
31430
  let resolvedPath = await discoverUPGFile(values.file);
30979
31431
  if (!resolvedPath) {
30980
- const defaultFile = path6.resolve("product.upg");
31432
+ const defaultFile = path7.resolve("product.upg");
30981
31433
  const title = values.title ?? "My Product";
30982
31434
  const blank = {
30983
31435
  upg_version: UPG_VERSION,
@@ -30993,16 +31445,16 @@ async function runMcpServer() {
30993
31445
  nodes: [],
30994
31446
  edges: []
30995
31447
  };
30996
- await fs3.mkdir(path6.dirname(defaultFile), { recursive: true });
30997
- await fs3.writeFile(defaultFile, serializeCanonical(blank), "utf-8");
31448
+ await fs4.mkdir(path7.dirname(defaultFile), { recursive: true });
31449
+ await fs4.writeFile(defaultFile, serializeCanonical(blank), "utf-8");
30998
31450
  process.stderr.write(`Created new UPG file: ${defaultFile}
30999
31451
  `);
31000
31452
  resolvedPath = defaultFile;
31001
31453
  } else {
31002
31454
  try {
31003
- await fs3.access(resolvedPath);
31455
+ await fs4.access(resolvedPath);
31004
31456
  } catch {
31005
- const title = values.title ?? path6.basename(resolvedPath, ".upg");
31457
+ const title = values.title ?? path7.basename(resolvedPath, ".upg");
31006
31458
  const blank = {
31007
31459
  upg_version: UPG_VERSION,
31008
31460
  exported_at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -31017,13 +31469,13 @@ async function runMcpServer() {
31017
31469
  nodes: [],
31018
31470
  edges: []
31019
31471
  };
31020
- await fs3.mkdir(path6.dirname(resolvedPath), { recursive: true });
31021
- await fs3.writeFile(resolvedPath, serializeCanonical(blank), "utf-8");
31472
+ await fs4.mkdir(path7.dirname(resolvedPath), { recursive: true });
31473
+ await fs4.writeFile(resolvedPath, serializeCanonical(blank), "utf-8");
31022
31474
  process.stderr.write(`Created new UPG file: ${resolvedPath}
31023
31475
  `);
31024
31476
  }
31025
31477
  }
31026
- const store = new UPGFileStore();
31478
+ const store = new UPGFileStore2();
31027
31479
  store.setWriter("upg-mcp-local", SERVER_VERSION);
31028
31480
  await store.load(resolvedPath);
31029
31481
  const nodes = store.getAllNodes();