hoi4-agent-tools 2.3.9 → 2.3.11

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/hoi4_agent_tools/core/workspace.js +24 -0
  3. package/dist/hoi4_agent_tools/focus/compact.js +44 -45
  4. package/dist/hoi4_agent_tools/focus/layout.js +71 -0
  5. package/dist/hoi4_agent_tools/mcp/server/base-tools.js +17 -0
  6. package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -4
  7. package/dist/hoi4_agent_tools/version.js +1 -1
  8. package/docs/focus.md +1 -1
  9. package/package.json +1 -1
  10. package/schemas/configuration.schema.json +1 -1
  11. package/schemas/continuous-focus-palette.schema.json +1 -1
  12. package/schemas/custom-weighted-pool.schema.json +1 -1
  13. package/schemas/event-feature-manifest.schema.json +1 -1
  14. package/schemas/focus-plan.schema.json +1 -1
  15. package/schemas/focus-planning-sidecar.schema.json +1 -1
  16. package/schemas/gui-animation-source.schema.json +1 -1
  17. package/schemas/gui-helper.schema.json +1 -1
  18. package/schemas/gui-scenario.schema.json +1 -1
  19. package/schemas/map-operation.schema.json +1 -1
  20. package/schemas/operation-result.schema.json +1 -1
  21. package/schemas/probability-analysis-result.schema.json +1 -1
  22. package/schemas/probability-compare-input.schema.json +1 -1
  23. package/schemas/probability-evaluate-input.schema.json +1 -1
  24. package/schemas/probability-inspect-input.schema.json +1 -1
  25. package/schemas/probability-render-input.schema.json +1 -1
  26. package/schemas/probability-scenario-set.schema.json +1 -1
  27. package/schemas/probability-sequence-input.schema.json +1 -1
  28. package/schemas/probability-simulate-input.schema.json +1 -1
  29. package/schemas/probability-sweep-input.schema.json +1 -1
  30. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.11 - 2026-08-05
4
+
5
+ - Made compact focus rewrites straighten offset linear chains and reject artificial staircase or zigzag geometry.
6
+ - Tightened compact layout acceptance so long connectors, connector paths through focuses, crossings, asymmetric sibling groups, and off-anchor branches cannot survive a successful cleanup.
7
+ - Kept the stricter rewrite deterministic and verified it through the public 1,024-focus MCP workflow.
8
+
9
+ ## 2.3.10 - 2026-08-04
10
+
11
+ - Routed focus operations to the unique discovered mod containing a requested source when a stale client root points at another mod.
12
+ - Added regression coverage for a stale client context requesting Ireland's focus source without a workspace selector.
13
+
3
14
  ## 2.3.9 - 2026-08-04
4
15
 
5
16
  - Bound omitted-workspace tool calls to the connected MCP client's active filesystem root instead of the server process's original working directory.
@@ -261,6 +261,30 @@ export class WorkspaceResolver {
261
261
  }
262
262
  throw new ServiceError('WORKSPACE_CONTEXT_AMBIGUOUS', 'The active MCP client roots identify more than one HOI4 mod workspace', { workspaceIds: matches.map(({ id }) => id) });
263
263
  }
264
+ /** Find one mod containing an existing source path when a stale client root selected another mod. */
265
+ async resolveUniqueModSourceWorkspaceId(relativePath, principal, signal) {
266
+ const matches = [];
267
+ for (const workspace of this.list(principal)) {
268
+ signal?.throwIfAborted();
269
+ if (workspace.registration.kind !== 'mod')
270
+ continue;
271
+ try {
272
+ const resolved = await this.resolvePath(workspace.id, relativePath, 'read', ['mod'], principal);
273
+ signal?.throwIfAborted();
274
+ if ((await lstat(resolved.path)).isFile())
275
+ matches.push(workspace.id);
276
+ }
277
+ catch (error) {
278
+ if (error.name === 'AbortError')
279
+ throw error;
280
+ }
281
+ }
282
+ if (matches.length === 0)
283
+ return undefined;
284
+ if (matches.length === 1)
285
+ return matches[0];
286
+ throw new ServiceError('WORKSPACE_SOURCE_AMBIGUOUS', `The requested source exists in more than one mod workspace: ${relativePath}`, { relativePath, workspaceIds: matches });
287
+ }
264
288
  resolveWorkspaceId(workspaceId, principal) {
265
289
  if (workspaceId === CURRENT_WORKSPACE_ID)
266
290
  return this.resolveCurrentWorkspaceId(principal);
@@ -10,14 +10,18 @@ const LARGE_COMPACT_SCALES = [0.55, 0.6, 0.7, 0.8];
10
10
  const COMPACT_LAYOUT_WORK_MAX = FOCUS_LAYOUT_WORK_MAX * 10;
11
11
  const COMPACT_HARD_DIAGNOSTICS = new Set([
12
12
  'FOCUS_LAYOUT_CONNECTOR_CROSSING_UNSATISFIED',
13
+ 'FOCUS_LAYOUT_CONNECTOR_THROUGH_NODE',
13
14
  'FOCUS_LAYOUT_COORDINATE_CONFLICT',
15
+ 'FOCUS_LAYOUT_LINEAR_DETOUR',
14
16
  'FOCUS_LAYOUT_LANE_BOUNDS_UNSATISFIED',
15
17
  'FOCUS_LAYOUT_MUTUAL_EXCLUSION_SPACING_UNSATISFIED',
16
18
  'FOCUS_LAYOUT_PARENT_ORDER_UNSATISFIED',
17
19
  'FOCUS_LAYOUT_SAME_ROW_SPACING_UNSATISFIED',
20
+ 'FOCUS_LAYOUT_STAIRCASE_CHAIN',
18
21
  'FOCUS_LAYOUT_VISIBLE_OVERLAP',
22
+ 'FOCUS_LAYOUT_ZIGZAG_CHAIN',
19
23
  ]);
20
- function compactLimits(focusCount, connectorCount) {
24
+ function compactLimits(focusCount) {
21
25
  const graphScale = Math.sqrt(Math.max(1, focusCount));
22
26
  const maximumHorizontalSpan = Math.max(14, Math.ceil(graphScale * 3));
23
27
  const maximumVerticalSpan = Math.max(4, Math.ceil(Math.log2(Math.max(2, focusCount))));
@@ -26,10 +30,6 @@ function compactLimits(focusCount, connectorCount) {
26
30
  maximumHorizontalSpan,
27
31
  maximumVerticalSpan,
28
32
  maximumManhattanSpan: maximumHorizontalSpan + maximumVerticalSpan,
29
- maximumLongConnectors: Math.ceil(connectorCount * 0.05),
30
- maximumNodeIntersections: Math.ceil(connectorCount * 0.12),
31
- maximumSiblingDeviation: Math.max(2, Math.ceil(graphScale * 2)),
32
- maximumTotalSiblingDeviation: Math.max(4, Math.ceil(focusCount * 0.5)),
33
33
  };
34
34
  }
35
35
  function requiredMetrics(layout) {
@@ -39,26 +39,18 @@ function requiredMetrics(layout) {
39
39
  }
40
40
  function absoluteCompactRegressions(layout) {
41
41
  const metrics = requiredMetrics(layout);
42
- const limits = compactLimits(layout.nodes.length, metrics.connectors.count);
42
+ const limits = compactLimits(layout.nodes.length);
43
43
  return [
44
44
  ...(layout.diagnostics.some(({ code }) => COMPACT_HARD_DIAGNOSTICS.has(code))
45
45
  ? ['hardLayoutDiagnostics']
46
46
  : []),
47
47
  ...(metrics.connectors.crossingCount > 0 ? ['connectorCrossingCount'] : []),
48
- ...(metrics.connectors.nodeIntersectionCount > limits.maximumNodeIntersections
49
- ? ['connectorNodeIntersectionBudget']
50
- : []),
48
+ ...(metrics.connectors.nodeIntersectionCount > 0 ? ['connectorNodeIntersections'] : []),
51
49
  ...(metrics.spacing.tooCloseSameRowPairCount > 0 ? ['sameRowSpacing'] : []),
52
- ...(metrics.symmetry.maximumSiblingDeviation > 1 ? ['siblingMirrorDeviation'] : []),
53
- ...(metrics.symmetry.totalSiblingDeviation > metrics.symmetry.siblingCohortCount
54
- ? ['totalSiblingMirrorDeviation']
55
- : []),
56
- ...(metrics.symmetry.maximumSiblingAnchorDeviation > limits.maximumSiblingDeviation
57
- ? ['maximumSiblingDeviationBudget']
58
- : []),
59
- ...(metrics.symmetry.totalSiblingAnchorDeviation > limits.maximumTotalSiblingDeviation
60
- ? ['totalSiblingDeviationBudget']
61
- : []),
50
+ ...(metrics.symmetry.asymmetricSiblingCohortCount > 0 ? ['siblingMirrorDeviation'] : []),
51
+ ...(metrics.symmetry.totalSiblingDeviation > 0 ? ['totalSiblingMirrorDeviation'] : []),
52
+ ...(metrics.symmetry.offAnchorSiblingCohortCount > 0 ? ['siblingAnchorDeviation'] : []),
53
+ ...(metrics.symmetry.totalSiblingAnchorDeviation > 0 ? ['totalSiblingAnchorDeviation'] : []),
62
54
  ...(metrics.symmetry.boundingCenterOffsetTwice > 1 ? ['boundingCenter'] : []),
63
55
  ...(metrics.bounds.columnCount > limits.maximumColumns ? ['columnBudget'] : []),
64
56
  ...(metrics.connectors.maximumHorizontalSpan > limits.maximumHorizontalSpan
@@ -70,9 +62,7 @@ function absoluteCompactRegressions(layout) {
70
62
  ...(metrics.connectors.maximumManhattanSpan > limits.maximumManhattanSpan
71
63
  ? ['maximumManhattanConnectorBudget']
72
64
  : []),
73
- ...(metrics.connectors.longConnectorCount > limits.maximumLongConnectors
74
- ? ['longConnectorBudget']
75
- : []),
65
+ ...(metrics.connectors.longConnectorCount > 0 ? ['longConnectors'] : []),
76
66
  ];
77
67
  }
78
68
  function relativeCompactRegressions(current, proposed) {
@@ -161,11 +151,8 @@ function compactStructuralAnchorX(plan, focus, parents, preferredX, laneBases) {
161
151
  function mirrorSiblingCohorts(plan, preferredX, preferredY) {
162
152
  const cohorts = new Map();
163
153
  const laneBases = compactLaneBases(plan);
164
- const childCounts = new Map();
165
154
  for (const focus of plan.focuses) {
166
155
  const parents = prerequisiteIds(plan, focus.id);
167
- for (const parentId of parents)
168
- childCounts.set(parentId, (childCounts.get(parentId) ?? 0) + 1);
169
156
  const key = JSON.stringify([
170
157
  parents.length === 0 ? focusLaneId(plan, focus) : null,
171
158
  parents,
@@ -184,11 +171,6 @@ function mirrorSiblingCohorts(plan, preferredX, preferredY) {
184
171
  const focus = plan.focuses.find(({ id }) => id === cohort[0]);
185
172
  if (focus === undefined)
186
173
  continue;
187
- // A leaf fan can be mirrored directly around its structural parent. For
188
- // rooted subtrees, moving only the entry focuses would push the long
189
- // connectors into their descendants; keep the subtree envelope's center
190
- // until the bounded gateway refinement can move the common parent.
191
- const leafFan = cohort.every((focusId) => (childCounts.get(focusId) ?? 0) === 0);
192
174
  const mirroredAt = (centerTwice) => {
193
175
  const mirrored = [...coordinates];
194
176
  for (let index = 0; index < pairCount; index += 1) {
@@ -209,24 +191,40 @@ function mirrorSiblingCohorts(plan, preferredX, preferredY) {
209
191
  }
210
192
  return mirrored;
211
193
  };
212
- const selfCenterTwice = (coordinates[0] ?? 0) + (coordinates.at(-1) ?? 0);
213
194
  const anchorCenterTwice = 2 *
214
195
  compactStructuralAnchorX(plan, focus, prerequisiteIds(plan, focus.id), preferredX, laneBases);
215
- let mirrored = mirroredAt(leafFan ? anchorCenterTwice : selfCenterTwice);
216
- if (leafFan) {
217
- const cohortIds = new Set(cohort);
218
- const y = preferredY.get(cohort[0] ?? '');
219
- const otherCoordinates = plan.focuses.flatMap((candidate) => !cohortIds.has(candidate.id) && preferredY.get(candidate.id) === y
220
- ? [preferredX.get(candidate.id) ?? 0]
221
- : []);
222
- if (mirrored.some((coordinate) => otherCoordinates.some((otherCoordinate) => Math.abs(coordinate - otherCoordinate) < COMPACT_SAME_ROW_SPACING))) {
223
- mirrored = mirroredAt(selfCenterTwice);
224
- }
225
- }
196
+ const mirrored = mirroredAt(anchorCenterTwice);
226
197
  for (const [index, focusId] of cohort.entries())
227
198
  preferredX.set(focusId, mirrored[index] ?? 0);
228
199
  }
229
200
  }
201
+ function straightenLinearChains(plan, preferredX, preferredY) {
202
+ const parentIds = new Map(plan.focuses.map((focus) => [focus.id, prerequisiteIds(plan, focus.id)]));
203
+ const childIds = new Map();
204
+ for (const [focusId, parents] of parentIds) {
205
+ for (const parentId of parents) {
206
+ const children = childIds.get(parentId) ?? [];
207
+ children.push(focusId);
208
+ childIds.set(parentId, children);
209
+ }
210
+ }
211
+ const ordered = [...plan.focuses].sort((left, right) => (preferredY.get(left.id) ?? 0) - (preferredY.get(right.id) ?? 0) ||
212
+ compareCodeUnits(left.id, right.id));
213
+ for (const focus of ordered) {
214
+ const parents = parentIds.get(focus.id) ?? [];
215
+ if (parents.length !== 1)
216
+ continue;
217
+ const parentId = parents[0];
218
+ if (parentId === undefined || (childIds.get(parentId)?.length ?? 0) !== 1)
219
+ continue;
220
+ const parentX = preferredX.get(parentId);
221
+ const parentY = preferredY.get(parentId);
222
+ if (parentX === undefined || parentY === undefined)
223
+ continue;
224
+ preferredX.set(focus.id, parentX);
225
+ preferredY.set(focus.id, parentY + 1);
226
+ }
227
+ }
230
228
  function compactCandidate(plan, layout, scale, compressRows) {
231
229
  const minimumX = Math.min(...layout.nodes.map(({ x }) => x));
232
230
  const maximumX = Math.max(...layout.nodes.map(({ x }) => x));
@@ -259,6 +257,7 @@ function compactCandidate(plan, layout, scale, compressRows) {
259
257
  preferredX.set(node.id, (placed[index] ?? 0) + drift);
260
258
  }
261
259
  mirrorSiblingCohorts(plan, preferredX, preferredY);
260
+ straightenLinearChains(plan, preferredX, preferredY);
262
261
  const compacted = structuredClone(plan);
263
262
  compacted.laneGroups = compacted.laneGroups.map(({ id, label, order }) => ({
264
263
  id,
@@ -328,7 +327,7 @@ export function compactFocusTreePlan(plan) {
328
327
  let selected;
329
328
  let fallback;
330
329
  for (const scale of scales) {
331
- for (const compressRows of plan.focuses.length <= 500 ? [false, true] : [false]) {
330
+ for (const compressRows of [false, true]) {
332
331
  const candidate = compactCandidate(plan, currentLayout, scale, compressRows);
333
332
  let layout;
334
333
  try {
@@ -378,7 +377,7 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
378
377
  let fallback;
379
378
  let exhausted;
380
379
  candidateSearch: for (const scale of compactScales(plan.focuses.length)) {
381
- for (const compressRows of plan.focuses.length <= 500 ? [false, true] : [false]) {
380
+ for (const compressRows of [false, true]) {
382
381
  options.signal?.throwIfAborted();
383
382
  const candidate = compactCandidate(plan, currentLayout, scale, compressRows);
384
383
  let layout;
@@ -425,7 +424,7 @@ export function assertCompactLayoutQuality(current, proposed) {
425
424
  throw new ServiceError('FOCUS_COMPACT_QUALITY_BLOCKED', 'Compact focus reflow failed the absolute or relative layout-quality gate', {
426
425
  treeId: proposed.treeId,
427
426
  regressions,
428
- limits: compactLimits(proposed.nodes.length, requiredMetrics(proposed).connectors.count),
427
+ limits: compactLimits(proposed.nodes.length),
429
428
  ...(current === undefined ? {} : { before: requiredMetrics(current) }),
430
429
  proposed: requiredMetrics(proposed),
431
430
  });
@@ -1620,6 +1620,76 @@ function* connectorLengthDiagnostics(context, edges) {
1620
1620
  });
1621
1621
  }
1622
1622
  }
1623
+ function* fakeComplexityDiagnostics(context, edges) {
1624
+ const incomingCount = new Map();
1625
+ const outgoingCount = new Map();
1626
+ for (const edge of edges) {
1627
+ incomingCount.set(edge.childId, (incomingCount.get(edge.childId) ?? 0) + 1);
1628
+ outgoingCount.set(edge.parentId, (outgoingCount.get(edge.parentId) ?? 0) + 1);
1629
+ }
1630
+ const linearEdges = edges.filter(({ parentId, childId }) => (outgoingCount.get(parentId) ?? 0) === 1 && (incomingCount.get(childId) ?? 0) === 1);
1631
+ const next = new Map(linearEdges.map((edge) => [edge.parentId, edge]));
1632
+ const linearChildren = new Set(linearEdges.map(({ childId }) => childId));
1633
+ for (const [edgeIndex, edge] of linearEdges.entries()) {
1634
+ yield* cancellationCheckpoint(context.signal, edgeIndex, 32);
1635
+ const spans = connectorSpans(edge);
1636
+ if (spans.horizontal === 0 && spans.vertical === 1)
1637
+ continue;
1638
+ const focus = context.focuses.get(edge.childId);
1639
+ context.diagnostics.push({
1640
+ code: 'FOCUS_LAYOUT_LINEAR_DETOUR',
1641
+ severity: 'warning',
1642
+ category: 'layout',
1643
+ message: `Mechanically linear connector ${edge.parentId} -> ${edge.childId} uses an unnecessary visual detour`,
1644
+ ...(focus?.sourceLocation === undefined ? {} : { location: focus.sourceLocation }),
1645
+ details: {
1646
+ parentId: edge.parentId,
1647
+ childId: edge.childId,
1648
+ horizontalSpan: spans.horizontal,
1649
+ verticalSpan: spans.vertical,
1650
+ expectedHorizontalSpan: 0,
1651
+ expectedVerticalSpan: 1,
1652
+ },
1653
+ });
1654
+ }
1655
+ const starts = linearEdges
1656
+ .filter(({ parentId }) => !linearChildren.has(parentId))
1657
+ .sort((left, right) => compareCodeUnits(left.parentId, right.parentId));
1658
+ for (const start of starts) {
1659
+ const chain = [];
1660
+ const visited = new Set();
1661
+ let current = start;
1662
+ while (current !== undefined && !visited.has(current.parentId)) {
1663
+ visited.add(current.parentId);
1664
+ chain.push(current);
1665
+ current = next.get(current.childId);
1666
+ }
1667
+ const horizontalDirections = chain
1668
+ .map(({ parent, child }) => Math.sign(child.x - parent.x))
1669
+ .filter((direction) => direction !== 0);
1670
+ if (horizontalDirections.length < 2)
1671
+ continue;
1672
+ const sameDirection = horizontalDirections.every((direction) => direction === horizontalDirections[0]);
1673
+ const alternating = horizontalDirections.every((direction, index) => index === 0 || direction === -horizontalDirections[index - 1]);
1674
+ const code = sameDirection
1675
+ ? 'FOCUS_LAYOUT_STAIRCASE_CHAIN'
1676
+ : alternating
1677
+ ? 'FOCUS_LAYOUT_ZIGZAG_CHAIN'
1678
+ : undefined;
1679
+ if (code === undefined)
1680
+ continue;
1681
+ const focusIds = [chain[0].parentId, ...chain.map(({ childId }) => childId)];
1682
+ const focus = context.focuses.get(focusIds.at(-1) ?? '');
1683
+ context.diagnostics.push({
1684
+ code,
1685
+ severity: 'warning',
1686
+ category: 'layout',
1687
+ message: `${sameDirection ? 'Staircase' : 'Zigzag'} geometry makes the linear chain ${focusIds.join(' -> ')} look artificially complex`,
1688
+ ...(focus?.sourceLocation === undefined ? {} : { location: focus.sourceLocation }),
1689
+ details: { focusIds, horizontalDirections },
1690
+ });
1691
+ }
1692
+ }
1623
1693
  function siblingCohortAnchor(context, cohort) {
1624
1694
  const focus = context.focuses.get(cohort[0] ?? '');
1625
1695
  const coordinates = cohort
@@ -1917,6 +1987,7 @@ function* layoutFocusTreeSteps(plan, options = {}) {
1917
1987
  const nodeIntersectionCount = yield* connectorNodeIntersectionDiagnostics(context, edges);
1918
1988
  yield* connectorCrossingDiagnostics(context, crossingSummary.samples);
1919
1989
  yield* connectorLengthDiagnostics(context, edges);
1990
+ yield* fakeComplexityDiagnostics(context, edges);
1920
1991
  const symmetry = yield* siblingSymmetryDiagnostics(context);
1921
1992
  const nodes = [...context.placed.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
1922
1993
  const decisions = [...context.decisions].sort((left, right) => compareCodeUnits(left.focusId, right.focusId));
@@ -1,4 +1,5 @@
1
1
  import { boundedSourceHashEvidence, publicArtifactLink } from '../../core/artifacts.js';
2
+ import { lstat } from 'node:fs/promises';
2
3
  import { canonicalJson } from '../../core/canonical.js';
3
4
  import { exactChangedFilePatterns, focusDomainScanPatterns, } from '../../core/domain-scan-patterns.js';
4
5
  import { ServiceError } from '../../core/result.js';
@@ -14,6 +15,22 @@ export async function resolveServerWorkspaceId(engine, context, workspaceId, sig
14
15
  }
15
16
  return engine.resolver.resolveWorkspaceId(workspaceId, context.principal);
16
17
  }
18
+ export async function resolveServerWorkspaceForSource(engine, context, workspaceId, relativePath, signal) {
19
+ const resolvedWorkspaceId = await resolveServerWorkspaceId(engine, context, workspaceId, signal);
20
+ if (workspaceId !== 'current' || relativePath === undefined)
21
+ return resolvedWorkspaceId;
22
+ try {
23
+ const resolved = await engine.resolver.resolvePath(resolvedWorkspaceId, relativePath, 'read', ['mod'], context.principal);
24
+ signal?.throwIfAborted();
25
+ if ((await lstat(resolved.path)).isFile())
26
+ return resolvedWorkspaceId;
27
+ }
28
+ catch (error) {
29
+ if (error.name === 'AbortError')
30
+ throw error;
31
+ }
32
+ return ((await engine.resolver.resolveUniqueModSourceWorkspaceId(relativePath, context.principal, signal)) ?? resolvedWorkspaceId);
33
+ }
17
34
  export function requireServerScope(context, scope) {
18
35
  if (context.scopes !== undefined && !context.scopes.includes(scope)) {
19
36
  throw new ServiceError('AUTH_SCOPE_REQUIRED', `This operation requires the ${scope} scope`, {
@@ -13,7 +13,7 @@ import { PACKAGE_VERSION } from '../../version.js';
13
13
  import { nonNegativeIntegerSchema, renderHashesSchema, sha256Schema, } from '../server/output-schemas.js';
14
14
  import { progressReporter } from '../server/progress.js';
15
15
  import { errorResult, setInlineFilesScanned, strictOperationResultSchema, toolResult, } from '../server/result.js';
16
- import { requireServerScope, resolveServerWorkspaceId, } from '../server/base-tools.js';
16
+ import { requireServerScope, resolveServerWorkspaceForSource, } from '../server/base-tools.js';
17
17
  import { compactValidatedInputSchema } from '../server/context-schemas.js';
18
18
  import { autonomousFailureContext, autonomousResultArtifacts, executePlannedTransaction, } from '../server/transaction-execution.js';
19
19
  const focusInspectOutputSchema = strictOperationResultSchema(z
@@ -347,7 +347,7 @@ export async function computeFocusVisualRevisions(engine, context, workspaceId,
347
347
  }
348
348
  async function executeFocusVisualTool(engine, workbench, context, input, extra, rasterize) {
349
349
  const { workspaceId: requestedWorkspaceId, relativePath } = input;
350
- const workspaceId = await resolveServerWorkspaceId(engine, context, requestedWorkspaceId, extra.signal);
350
+ const workspaceId = await resolveServerWorkspaceForSource(engine, context, requestedWorkspaceId, relativePath, extra.signal);
351
351
  const outputName = rasterize ? 'raster' : 'render';
352
352
  try {
353
353
  const progress = progressReporter(extra);
@@ -607,7 +607,7 @@ export function registerFocusTools(server, engine, context) {
607
607
  annotations: artifactProducing,
608
608
  }, async (input, extra) => {
609
609
  const { workspaceId: requestedWorkspaceId, relativePath } = input;
610
- const workspaceId = await resolveServerWorkspaceId(engine, context, requestedWorkspaceId, extra.signal);
610
+ const workspaceId = await resolveServerWorkspaceForSource(engine, context, requestedWorkspaceId, relativePath, extra.signal);
611
611
  try {
612
612
  const progress = progressReporter(extra);
613
613
  await progress.report(0, 3, 'Building shared workspace index');
@@ -845,7 +845,7 @@ export function registerFocusTools(server, engine, context) {
845
845
  },
846
846
  }, async (input, extra) => {
847
847
  const { workspaceId: requestedWorkspaceId, relativePath, createIfMissing, horizontalSpacing, verticalSpacing, padding, reviewScale, } = input;
848
- const workspaceId = await resolveServerWorkspaceId(engine, context, requestedWorkspaceId, extra.signal);
848
+ const workspaceId = await resolveServerWorkspaceForSource(engine, context, requestedWorkspaceId, relativePath, extra.signal);
849
849
  try {
850
850
  requireServerScope(context, 'hoi4:write');
851
851
  const progress = progressReporter(extra);
@@ -1,6 +1,6 @@
1
1
  import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const PACKAGE_NAME = 'hoi4-agent-tools';
3
- export const PACKAGE_VERSION = '2.3.9';
3
+ export const PACKAGE_VERSION = '2.3.11';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;
package/docs/focus.md CHANGED
@@ -30,7 +30,7 @@ For a layout-only cleanup of an existing national tree, call `hoi4.focus_rewrite
30
30
 
31
31
  Compact mode imports the tree, resets authored presentation coordinates and lane bounds, and performs a deterministic reflow in the same rewrite call. It keeps prerequisites, mutual exclusions, availability, bypasses, rewards, icons, localisation, AI behavior, and route/lane membership unchanged.
32
32
 
33
- The rewrite measures canvas bounds, same-row spacing, the same curved connector paths used by the renderer, connector lengths, connector-node intersections, branch balance, and overall centering. A bounded refinement pass moves automatic gateways and blockers only when the measured connector result improves. The rewrite refuses changes with close focuses, rendered crossings, excessive branch imbalance, an off-center canvas, or connector and size metrics outside absolute and source-relative limits.
33
+ The rewrite measures canvas bounds, same-row spacing, the same curved connector paths used by the renderer, connector lengths, connector-node intersections, branch balance, overall centering, and topology-aware fake-complexity patterns. Compact mode straightens mechanically linear chains and refuses a result with overlapping or cramped focuses, rendered crossings, connectors through unrelated focuses, long connectors, asymmetric or off-anchor sibling groups, offset linear detours, staircase or zigzag chains, an off-center canvas, or size metrics outside absolute and source-relative limits.
34
34
 
35
35
  Use the default `layoutMode: "authored"` with a complete plan when deliberately designing coordinates or changing gameplay. Set `layoutMode: "compact"` with a complete plan when creating a large automatically arranged tree. Inspect and render calls remain useful before or after either rewrite mode, but a separate plan is not required for compact cleanup.
36
36
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hoi4-agent-tools",
3
3
  "mcpName": "io.github.klimPaskov/hoi4-agent-tools",
4
- "version": "2.3.9",
4
+ "version": "2.3.11",
5
5
  "description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
6
6
  "type": "module",
7
7
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/configuration.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/configuration.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/continuous-focus-palette.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/continuous-focus-palette.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/custom-weighted-pool.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/custom-weighted-pool.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/event-feature-manifest.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/event-feature-manifest.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/focus-plan.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/focus-plan.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/focus-planning-sidecar.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/focus-planning-sidecar.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/gui-animation-source.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/gui-animation-source.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/gui-helper.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/gui-helper.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/gui-scenario.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/gui-scenario.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/map-operation.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/map-operation.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "oneOf": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/operation-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/operation-result.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/probability-analysis-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/probability-analysis-result.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/probability-compare-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/probability-compare-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/probability-evaluate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/probability-evaluate-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/probability-inspect-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/probability-inspect-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/probability-render-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/probability-render-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/probability-scenario-set.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/probability-scenario-set.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/probability-sequence-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/probability-sequence-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/probability-simulate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/probability-simulate-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.9/schemas/probability-sweep-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.11/schemas/probability-sweep-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.klimPaskov/hoi4-agent-tools",
4
4
  "title": "HOI4 Agent Tools",
5
5
  "description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
6
- "version": "2.3.9",
6
+ "version": "2.3.11",
7
7
  "repository": {
8
8
  "url": "https://github.com/klimPaskov/hoi4-agent-tools",
9
9
  "source": "github"
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "hoi4-agent-tools",
16
- "version": "2.3.9",
16
+ "version": "2.3.11",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }