hoi4-agent-tools 1.0.0 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 - 2026-07-12
4
+
5
+ - Added plan-free `compact` rewrites for existing national focus trees while retaining authored mode for complete plans and new trees.
6
+ - Added deterministic candidate reflow, vertical-gap compression, gateway refinement, spacing, rendered-curve crossing, connector-node, branch-balance, centering, and canvas metrics, with absolute and relative compact-layout gates.
7
+ - Preserved automatic placement intent in focus planning sidecars so later imports can continue deterministic layout work.
8
+ - Reduced MCP tool-discovery payload size by about 83% while keeping full runtime validation, and bounded inline results so large plans and artifacts stay in MCP resources.
9
+ - Verified coexistence with repository `AGENTS.md` instructions, skills, plans, and subagent workflows; the server exposes domain tools without taking over task orchestration.
10
+ - Refreshed the authentication and lint dependencies while retaining the current MCP SDK, Inspector, protocol, and Registry publisher versions.
11
+
3
12
  ## 1.0.0 - 2026-07-12
4
13
 
5
14
  - Reduced the MCP surface to ten tools for discovering mods and creating, inspecting, rendering, and rewriting focus trees, scripted GUIs, and maps.
package/README.md CHANGED
@@ -13,7 +13,7 @@ HOI4 Agent Tools is an MCP server that helps coding agents inspect, create, and
13
13
  Requires Node.js 22 or 24.
14
14
 
15
15
  ```bash
16
- npm install --global hoi4-agent-tools@1.0.0
16
+ npm install --global hoi4-agent-tools@1.1.0
17
17
  hoi4-agent-tools-setup --init
18
18
  ```
19
19
 
@@ -44,11 +44,17 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
44
44
 
45
45
  Large outputs are linked `hoi4-agent://` resources. For resources over 1 MiB, follow the `continuationUri` returned in `_meta` until it is `null`; clients may also request byte ranges with `?offset=<bytes>&length=<bytes>`.
46
46
 
47
+ ## Coexistence with agent workflows
48
+
49
+ HOI4 Agent Tools provides HOI4 domain operations; it does not register MCP prompts, replace repository instructions such as `AGENTS.md`, manage skills or plans, or start subagents. The coding agent decides when to call it as part of its existing workflow.
50
+
51
+ Connecting and listing tools does not scan mod source. Compact tool schemas and linked resources keep large diagnostics, renders, and diffs out of the agent's working context until needed. Only `hoi4.*_rewrite` calls edit mod source.
52
+
47
53
  ## Create or clean content
48
54
 
49
55
  Ask your agent in normal task language. A typical workflow is inspect, render, rewrite, then inspect the result.
50
56
 
51
- - Focus trees: "Create a complete national focus tree for this route specification," or "Clean up this tree's overlaps and route crossings without changing its prerequisites or rewards." See [Focus trees](docs/focus.md).
57
+ - Focus trees: "Create a complete national focus tree for this route specification," or "Compact this existing tree into a balanced, readable layout." Existing trees can use a plan-free compact reflow; new trees use a complete plan. See [Focus trees](docs/focus.md).
52
58
  - Scripted GUIs: "Create a scripted GUI for this mechanic," or "Render this window at common resolutions and fix clipping, missing assets, and click-region conflicts." See [Scripted GUIs](docs/gui.md).
53
59
  - Maps: "Create a state from these exact provinces," or "Inspect this state and split these provinces while keeping supply and railway references valid." See [Maps](docs/map.md).
54
60
 
@@ -0,0 +1,432 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ import { ServiceError } from '../core/result.js';
3
+ import { focusPlanHash, } from './model.js';
4
+ import { FOCUS_LAYOUT_WORK_MAX } from './limits.js';
5
+ import { FocusLayoutWorkBudget, layoutFocusTree, layoutFocusTreeAsync } from './layout.js';
6
+ const COMPACT_SAME_ROW_SPACING = 2;
7
+ const COMPACT_LANE_SPACING = 8;
8
+ const SMALL_COMPACT_SCALES = [0.2, 0.3, 0.4, 0.55, 0.7, 0.9];
9
+ const LARGE_COMPACT_SCALES = [0.55, 0.6, 0.7, 0.8];
10
+ const COMPACT_LAYOUT_WORK_MAX = FOCUS_LAYOUT_WORK_MAX * 10;
11
+ const COMPACT_HARD_DIAGNOSTICS = new Set([
12
+ 'FOCUS_LAYOUT_CONNECTOR_CROSSING_UNSATISFIED',
13
+ 'FOCUS_LAYOUT_COORDINATE_CONFLICT',
14
+ 'FOCUS_LAYOUT_LANE_BOUNDS_UNSATISFIED',
15
+ 'FOCUS_LAYOUT_MUTUAL_EXCLUSION_SPACING_UNSATISFIED',
16
+ 'FOCUS_LAYOUT_PARENT_ORDER_UNSATISFIED',
17
+ 'FOCUS_LAYOUT_SAME_ROW_SPACING_UNSATISFIED',
18
+ 'FOCUS_LAYOUT_VISIBLE_OVERLAP',
19
+ ]);
20
+ function compactLimits(focusCount, connectorCount) {
21
+ const graphScale = Math.sqrt(Math.max(1, focusCount));
22
+ const maximumHorizontalSpan = Math.max(14, Math.ceil(graphScale * 3));
23
+ const maximumVerticalSpan = Math.max(4, Math.ceil(Math.log2(Math.max(2, focusCount))));
24
+ return {
25
+ maximumColumns: Math.max(16, Math.ceil(graphScale * 5)),
26
+ maximumHorizontalSpan,
27
+ maximumVerticalSpan,
28
+ 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
+ };
34
+ }
35
+ function requiredMetrics(layout) {
36
+ if (layout.metrics !== undefined)
37
+ return layout.metrics;
38
+ throw new ServiceError('FOCUS_COMPACT_METRICS_REQUIRED', 'Compact focus reflow requires current layout quality metrics', { treeId: layout.treeId });
39
+ }
40
+ function absoluteCompactRegressions(layout) {
41
+ const metrics = requiredMetrics(layout);
42
+ const limits = compactLimits(layout.nodes.length, metrics.connectors.count);
43
+ return [
44
+ ...(layout.diagnostics.some(({ code }) => COMPACT_HARD_DIAGNOSTICS.has(code))
45
+ ? ['hardLayoutDiagnostics']
46
+ : []),
47
+ ...(metrics.connectors.crossingCount > 0 ? ['connectorCrossingCount'] : []),
48
+ ...(metrics.connectors.nodeIntersectionCount > limits.maximumNodeIntersections
49
+ ? ['connectorNodeIntersectionBudget']
50
+ : []),
51
+ ...(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
+ : []),
62
+ ...(metrics.symmetry.boundingCenterOffsetTwice > 1 ? ['boundingCenter'] : []),
63
+ ...(metrics.bounds.columnCount > limits.maximumColumns ? ['columnBudget'] : []),
64
+ ...(metrics.connectors.maximumHorizontalSpan > limits.maximumHorizontalSpan
65
+ ? ['maximumHorizontalConnectorBudget']
66
+ : []),
67
+ ...(metrics.connectors.maximumVerticalSpan > limits.maximumVerticalSpan
68
+ ? ['maximumVerticalConnectorBudget']
69
+ : []),
70
+ ...(metrics.connectors.maximumManhattanSpan > limits.maximumManhattanSpan
71
+ ? ['maximumManhattanConnectorBudget']
72
+ : []),
73
+ ...(metrics.connectors.longConnectorCount > limits.maximumLongConnectors
74
+ ? ['longConnectorBudget']
75
+ : []),
76
+ ];
77
+ }
78
+ function relativeCompactRegressions(current, proposed) {
79
+ const before = requiredMetrics(current);
80
+ const after = requiredMetrics(proposed);
81
+ return [
82
+ ...(after.connectors.crossingCount > before.connectors.crossingCount
83
+ ? ['connectorCrossingCount']
84
+ : []),
85
+ ...(after.connectors.nodeIntersectionCount > before.connectors.nodeIntersectionCount
86
+ ? ['connectorNodeIntersectionCount']
87
+ : []),
88
+ ...(after.connectors.maximumHorizontalSpan > before.connectors.maximumHorizontalSpan
89
+ ? ['maximumHorizontalConnectorSpan']
90
+ : []),
91
+ ...(after.connectors.maximumVerticalSpan > before.connectors.maximumVerticalSpan
92
+ ? ['maximumVerticalConnectorSpan']
93
+ : []),
94
+ ...(after.connectors.maximumManhattanSpan > before.connectors.maximumManhattanSpan
95
+ ? ['maximumManhattanConnectorSpan']
96
+ : []),
97
+ ...(after.connectors.longConnectorCount > before.connectors.longConnectorCount
98
+ ? ['longConnectorCount']
99
+ : []),
100
+ ...(after.connectors.totalHorizontalSpan > before.connectors.totalHorizontalSpan
101
+ ? ['totalHorizontalConnectorSpan']
102
+ : []),
103
+ ...(after.connectors.totalManhattanSpan > before.connectors.totalManhattanSpan
104
+ ? ['totalManhattanConnectorSpan']
105
+ : []),
106
+ ...(after.bounds.columnCount > before.bounds.columnCount ? ['columnCount'] : []),
107
+ ...(after.bounds.rowCount > before.bounds.rowCount ? ['rowCount'] : []),
108
+ ];
109
+ }
110
+ function compactShape(plan, layout) {
111
+ return (plan.focuses.every(({ position }) => position.mode === 'auto') &&
112
+ plan.laneGroups.every(({ minimumX, maximumX }) => minimumX === undefined && maximumX === undefined) &&
113
+ absoluteCompactRegressions(layout).length === 0);
114
+ }
115
+ function prerequisiteIds(plan, focusId) {
116
+ const focus = plan.focuses.find(({ id }) => id === focusId);
117
+ return (focus?.prerequisites.groups
118
+ .flatMap(({ focusIds }) => focusIds)
119
+ .sort((left, right) => compareCodeUnits(left, right)) ?? []);
120
+ }
121
+ function focusLaneId(plan, focus) {
122
+ if (focus.laneId !== undefined)
123
+ return focus.laneId;
124
+ if (focus.branchId !== undefined) {
125
+ const branch = plan.branchGroups.find(({ id }) => id === focus.branchId);
126
+ return branch?.laneId ?? focus.branchId;
127
+ }
128
+ return 'default';
129
+ }
130
+ function compactLaneBases(plan) {
131
+ const configured = [...plan.laneGroups].sort((left, right) => left.order - right.order || compareCodeUnits(left.id, right.id));
132
+ const configuredIds = new Set(configured.map(({ id }) => id));
133
+ const discovered = [...new Set(plan.focuses.map((focus) => focusLaneId(plan, focus)))]
134
+ .filter((id) => !configuredIds.has(id))
135
+ .sort((left, right) => compareCodeUnits(left, right));
136
+ const laneIds = [...configured.map(({ id }) => id), ...discovered];
137
+ return new Map(laneIds.map((id, index) => [
138
+ id,
139
+ Math.round((index - (laneIds.length - 1) / 2) * COMPACT_LANE_SPACING),
140
+ ]));
141
+ }
142
+ function compactStructuralAnchorX(plan, focus, parents, preferredX, laneBases) {
143
+ const parentCoordinates = parents
144
+ .flatMap((focusId) => {
145
+ const coordinate = preferredX.get(focusId);
146
+ return coordinate === undefined ? [] : [coordinate];
147
+ })
148
+ .sort((left, right) => left - right);
149
+ if (parentCoordinates.length > 1 && focus.convergence) {
150
+ return Math.floor(parentCoordinates.reduce((total, coordinate) => total + coordinate, 0) /
151
+ parentCoordinates.length);
152
+ }
153
+ if (parentCoordinates.length > 0) {
154
+ const middle = Math.floor(parentCoordinates.length / 2);
155
+ if (parentCoordinates.length % 2 === 1)
156
+ return parentCoordinates[middle] ?? 0;
157
+ return Math.floor(((parentCoordinates[middle - 1] ?? 0) + (parentCoordinates[middle] ?? 0)) / 2);
158
+ }
159
+ return laneBases.get(focusLaneId(plan, focus)) ?? 0;
160
+ }
161
+ function mirrorSiblingCohorts(plan, preferredX, preferredY) {
162
+ const cohorts = new Map();
163
+ const laneBases = compactLaneBases(plan);
164
+ const childCounts = new Map();
165
+ for (const focus of plan.focuses) {
166
+ const parents = prerequisiteIds(plan, focus.id);
167
+ for (const parentId of parents)
168
+ childCounts.set(parentId, (childCounts.get(parentId) ?? 0) + 1);
169
+ const key = JSON.stringify([
170
+ parents.length === 0 ? focusLaneId(plan, focus) : null,
171
+ parents,
172
+ preferredY.get(focus.id) ?? 0,
173
+ ]);
174
+ const cohort = cohorts.get(key) ?? [];
175
+ cohort.push(focus.id);
176
+ cohorts.set(key, cohort);
177
+ }
178
+ for (const cohort of cohorts.values()) {
179
+ if (cohort.length < 2)
180
+ continue;
181
+ cohort.sort((left, right) => (preferredX.get(left) ?? 0) - (preferredX.get(right) ?? 0) || compareCodeUnits(left, right));
182
+ const coordinates = cohort.map((id) => preferredX.get(id) ?? 0);
183
+ const pairCount = Math.floor(coordinates.length / 2);
184
+ const focus = plan.focuses.find(({ id }) => id === cohort[0]);
185
+ if (focus === undefined)
186
+ 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
+ const mirroredAt = (centerTwice) => {
193
+ const mirrored = [...coordinates];
194
+ for (let index = 0; index < pairCount; index += 1) {
195
+ const opposite = coordinates.length - 1 - index;
196
+ const left = Math.round(((coordinates[index] ?? 0) + centerTwice - (coordinates[opposite] ?? 0)) / 2);
197
+ mirrored[index] = left;
198
+ mirrored[opposite] = centerTwice - left;
199
+ }
200
+ if (coordinates.length % 2 === 1)
201
+ mirrored[pairCount] = Math.round(centerTwice / 2);
202
+ let maximumLeft = centerTwice / 2 - (coordinates.length % 2 === 1 ? 2 : 1);
203
+ for (let index = pairCount - 1; index >= 0; index -= 1) {
204
+ const opposite = coordinates.length - 1 - index;
205
+ const left = Math.min(mirrored[index] ?? maximumLeft, maximumLeft);
206
+ mirrored[index] = left;
207
+ mirrored[opposite] = centerTwice - left;
208
+ maximumLeft = left - COMPACT_SAME_ROW_SPACING;
209
+ }
210
+ return mirrored;
211
+ };
212
+ const selfCenterTwice = (coordinates[0] ?? 0) + (coordinates.at(-1) ?? 0);
213
+ const anchorCenterTwice = 2 *
214
+ 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
+ }
226
+ for (const [index, focusId] of cohort.entries())
227
+ preferredX.set(focusId, mirrored[index] ?? 0);
228
+ }
229
+ }
230
+ function compactCandidate(plan, layout, scale, compressRows) {
231
+ const minimumX = Math.min(...layout.nodes.map(({ x }) => x));
232
+ const maximumX = Math.max(...layout.nodes.map(({ x }) => x));
233
+ const minimumY = Math.min(...layout.nodes.map(({ y }) => y));
234
+ const centerX = (minimumX + maximumX) / 2;
235
+ const preferredX = new Map(layout.nodes.map((node) => [node.id, Math.round((node.x - centerX) * scale)]));
236
+ const sourceRows = [...new Set(layout.nodes.map(({ y }) => y))].sort((left, right) => left - right);
237
+ const compactRows = new Map(sourceRows.map((row, index) => [row, index]));
238
+ const preferredY = new Map(layout.nodes.map((node) => [
239
+ node.id,
240
+ compressRows ? (compactRows.get(node.y) ?? node.y - minimumY) : node.y - minimumY,
241
+ ]));
242
+ const rows = new Map();
243
+ for (const node of layout.nodes) {
244
+ const row = rows.get(node.y) ?? [];
245
+ row.push(node);
246
+ rows.set(node.y, row);
247
+ }
248
+ for (const row of rows.values()) {
249
+ row.sort((left, right) => left.x - right.x || compareCodeUnits(left.id, right.id));
250
+ const placed = [];
251
+ for (const [index, node] of row.entries()) {
252
+ const requested = preferredX.get(node.id) ?? 0;
253
+ placed.push(index === 0
254
+ ? requested
255
+ : Math.max(requested, (placed[index - 1] ?? 0) + COMPACT_SAME_ROW_SPACING));
256
+ }
257
+ const drift = Math.round(row.reduce((total, node, index) => total + (preferredX.get(node.id) ?? 0) - (placed[index] ?? 0), 0) / row.length);
258
+ for (const [index, node] of row.entries())
259
+ preferredX.set(node.id, (placed[index] ?? 0) + drift);
260
+ }
261
+ mirrorSiblingCohorts(plan, preferredX, preferredY);
262
+ const compacted = structuredClone(plan);
263
+ compacted.laneGroups = compacted.laneGroups.map(({ id, label, order }) => ({
264
+ id,
265
+ label,
266
+ order,
267
+ }));
268
+ for (const focus of compacted.focuses) {
269
+ focus.position = {
270
+ mode: 'auto',
271
+ pinned: false,
272
+ preferredX: preferredX.get(focus.id) ?? 0,
273
+ preferredY: preferredY.get(focus.id) ?? 0,
274
+ };
275
+ }
276
+ compacted.provenance.importedPlanHash = focusPlanHash(compacted);
277
+ return compacted;
278
+ }
279
+ function compactScore(layout) {
280
+ const metrics = requiredMetrics(layout);
281
+ return [
282
+ metrics.connectors.longConnectorCount,
283
+ metrics.connectors.maximumHorizontalSpan,
284
+ metrics.bounds.columnCount,
285
+ metrics.connectors.nodeIntersectionCount,
286
+ metrics.connectors.totalHorizontalSpan,
287
+ metrics.connectors.maximumManhattanSpan,
288
+ metrics.bounds.rowCount,
289
+ metrics.connectors.totalVerticalSpan,
290
+ layout.layoutHash,
291
+ ];
292
+ }
293
+ function betterScore(candidate, current) {
294
+ for (let index = 0; index < candidate.length; index += 1) {
295
+ const left = candidate[index];
296
+ const right = current[index];
297
+ if (left === right)
298
+ continue;
299
+ if (typeof left === 'number' && typeof right === 'number')
300
+ return left < right;
301
+ return String(left) < String(right);
302
+ }
303
+ return false;
304
+ }
305
+ function compactScales(focusCount) {
306
+ return focusCount <= 50
307
+ ? SMALL_COMPACT_SCALES
308
+ : focusCount <= 500
309
+ ? LARGE_COMPACT_SCALES
310
+ : focusCount <= 2_000
311
+ ? [0.55, 0.65, 0.75]
312
+ : [0.65];
313
+ }
314
+ /**
315
+ * Produces a gameplay-neutral compact-reflow plan. Several deterministic
316
+ * compression candidates are measured with the same layout engine; only a
317
+ * centered, crossing-free, evenly spaced, branch-balanced candidate that
318
+ * stays within absolute and source-relative connector budgets can win.
319
+ */
320
+ export function compactFocusTreePlan(plan) {
321
+ const currentLayout = layoutFocusTree(plan);
322
+ if (compactShape(plan, currentLayout)) {
323
+ const stable = structuredClone(plan);
324
+ stable.provenance.importedPlanHash = focusPlanHash(stable);
325
+ return stable;
326
+ }
327
+ const scales = compactScales(plan.focuses.length);
328
+ let selected;
329
+ let fallback;
330
+ for (const scale of scales) {
331
+ for (const compressRows of plan.focuses.length <= 500 ? [false, true] : [false]) {
332
+ const candidate = compactCandidate(plan, currentLayout, scale, compressRows);
333
+ let layout;
334
+ try {
335
+ layout = layoutFocusTree(candidate);
336
+ }
337
+ catch (error) {
338
+ if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED')
339
+ continue;
340
+ throw error;
341
+ }
342
+ if (fallback === undefined ||
343
+ betterScore(compactScore(layout), compactScore(fallback.layout)))
344
+ fallback = { plan: candidate, layout };
345
+ if (absoluteCompactRegressions(layout).length > 0 ||
346
+ relativeCompactRegressions(currentLayout, layout).length > 0)
347
+ continue;
348
+ if (selected === undefined ||
349
+ betterScore(compactScore(layout), compactScore(selected.layout)))
350
+ selected = { plan: candidate, layout };
351
+ }
352
+ }
353
+ if (selected !== undefined)
354
+ return selected.plan;
355
+ if (fallback !== undefined) {
356
+ assertCompactLayoutQuality(currentLayout, fallback.layout);
357
+ return fallback.plan;
358
+ }
359
+ throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
360
+ }
361
+ /**
362
+ * Cooperative compact planning for protocol handlers. Candidate layouts share
363
+ * one work budget, and the selected layout is returned for validation/rendering.
364
+ */
365
+ export async function compactFocusTreePlanAsync(plan, options = {}) {
366
+ const workBudget = new FocusLayoutWorkBudget(options.maximumWork ?? COMPACT_LAYOUT_WORK_MAX);
367
+ const layoutOptions = {
368
+ workBudget,
369
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
370
+ };
371
+ const currentLayout = await layoutFocusTreeAsync(plan, layoutOptions);
372
+ if (compactShape(plan, currentLayout)) {
373
+ const stable = structuredClone(plan);
374
+ stable.provenance.importedPlanHash = focusPlanHash(stable);
375
+ return { plan: stable, currentLayout, proposedLayout: currentLayout };
376
+ }
377
+ let selected;
378
+ let fallback;
379
+ let exhausted;
380
+ candidateSearch: for (const scale of compactScales(plan.focuses.length)) {
381
+ for (const compressRows of plan.focuses.length <= 500 ? [false, true] : [false]) {
382
+ options.signal?.throwIfAborted();
383
+ const candidate = compactCandidate(plan, currentLayout, scale, compressRows);
384
+ let layout;
385
+ try {
386
+ layout = await layoutFocusTreeAsync(candidate, layoutOptions);
387
+ }
388
+ catch (error) {
389
+ if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED') {
390
+ exhausted = error;
391
+ break candidateSearch;
392
+ }
393
+ throw error;
394
+ }
395
+ if (fallback === undefined ||
396
+ betterScore(compactScore(layout), compactScore(fallback.layout)))
397
+ fallback = { plan: candidate, layout };
398
+ if (absoluteCompactRegressions(layout).length > 0 ||
399
+ relativeCompactRegressions(currentLayout, layout).length > 0)
400
+ continue;
401
+ if (selected === undefined ||
402
+ betterScore(compactScore(layout), compactScore(selected.layout)))
403
+ selected = { plan: candidate, layout };
404
+ }
405
+ }
406
+ if (selected !== undefined) {
407
+ return { plan: selected.plan, currentLayout, proposedLayout: selected.layout };
408
+ }
409
+ if (exhausted !== undefined)
410
+ throw exhausted;
411
+ if (fallback !== undefined) {
412
+ assertCompactLayoutQuality(currentLayout, fallback.layout);
413
+ return { plan: fallback.plan, currentLayout, proposedLayout: fallback.layout };
414
+ }
415
+ throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
416
+ }
417
+ /** Refuses compact rewrites that are not clean or trade readability for a smaller canvas. */
418
+ export function assertCompactLayoutQuality(current, proposed) {
419
+ const regressions = [
420
+ ...absoluteCompactRegressions(proposed),
421
+ ...(current === undefined ? [] : relativeCompactRegressions(current, proposed)),
422
+ ].filter((value, index, all) => all.indexOf(value) === index);
423
+ if (regressions.length === 0)
424
+ return;
425
+ throw new ServiceError('FOCUS_COMPACT_QUALITY_BLOCKED', 'Compact focus reflow failed the absolute or relative layout-quality gate', {
426
+ treeId: proposed.treeId,
427
+ regressions,
428
+ limits: compactLimits(proposed.nodes.length, requiredMetrics(proposed).connectors.count),
429
+ ...(current === undefined ? {} : { before: requiredMetrics(current) }),
430
+ proposed: requiredMetrics(proposed),
431
+ });
432
+ }
@@ -90,13 +90,7 @@ function positionLines(focus, layout) {
90
90
  if (focus.position.mode === 'fixed') {
91
91
  return [`x = ${focus.position.x}`, `y = ${focus.position.y}`];
92
92
  }
93
- const placed = layoutNodeMap(layout ?? {
94
- treeId: '',
95
- nodes: [],
96
- decisions: [],
97
- diagnostics: [],
98
- layoutHash: '',
99
- }).get(focus.id);
93
+ const placed = layout === undefined ? undefined : layoutNodeMap(layout).get(focus.id);
100
94
  if (placed === undefined) {
101
95
  throw new ServiceError('FOCUS_LAYOUT_REQUIRED', `Automatic focus ${focus.id} has no layout position`, {
102
96
  focusId: focus.id,