hoi4-agent-tools 1.0.0 → 1.2.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +27 -16
  3. package/dist/hoi4_agent_tools/core/engine.js +12 -0
  4. package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
  5. package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
  6. package/dist/hoi4_agent_tools/event/compare.js +413 -0
  7. package/dist/hoi4_agent_tools/event/graph.js +1586 -0
  8. package/dist/hoi4_agent_tools/event/index.js +10 -0
  9. package/dist/hoi4_agent_tools/event/layout.js +218 -0
  10. package/dist/hoi4_agent_tools/event/limits.js +41 -0
  11. package/dist/hoi4_agent_tools/event/model.js +2 -0
  12. package/dist/hoi4_agent_tools/event/queries.js +643 -0
  13. package/dist/hoi4_agent_tools/event/render.js +443 -0
  14. package/dist/hoi4_agent_tools/event/scc.js +72 -0
  15. package/dist/hoi4_agent_tools/event/service.js +1147 -0
  16. package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
  17. package/dist/hoi4_agent_tools/focus/compact.js +432 -0
  18. package/dist/hoi4_agent_tools/focus/compiler.js +1 -7
  19. package/dist/hoi4_agent_tools/focus/geometry.js +203 -16
  20. package/dist/hoi4_agent_tools/focus/index.js +1 -0
  21. package/dist/hoi4_agent_tools/focus/layout.js +1256 -92
  22. package/dist/hoi4_agent_tools/focus/limits.js +1 -1
  23. package/dist/hoi4_agent_tools/focus/lint.js +11 -3
  24. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  25. package/dist/hoi4_agent_tools/focus/planning.js +6 -1
  26. package/dist/hoi4_agent_tools/focus/render.js +14 -9
  27. package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
  28. package/dist/hoi4_agent_tools/mcp/server/context-schemas.js +8 -0
  29. package/dist/hoi4_agent_tools/mcp/server/create.js +4 -1
  30. package/dist/hoi4_agent_tools/mcp/server/result.js +117 -52
  31. package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
  32. package/dist/hoi4_agent_tools/mcp/tools/focus.js +93 -7
  33. package/dist/hoi4_agent_tools/mcp/tools/gui.js +12 -9
  34. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -4
  35. package/dist/hoi4_agent_tools/mcp/tools/mods.js +1 -1
  36. package/dist/hoi4_agent_tools/schemas/event.js +95 -0
  37. package/dist/hoi4_agent_tools/schemas/focus.js +64 -8
  38. package/dist/hoi4_agent_tools/version.js +1 -1
  39. package/docs/README.md +1 -0
  40. package/docs/development.md +10 -0
  41. package/docs/events.md +113 -0
  42. package/docs/focus.md +17 -7
  43. package/docs/setup.md +3 -3
  44. package/package.json +9 -5
  45. package/server.json +3 -3
@@ -0,0 +1,459 @@
1
+ import { z } from 'zod/v4';
2
+ import { publicArtifactLink } from '../../core/artifacts.js';
3
+ import { compareCodeUnits } from '../../core/canonical.js';
4
+ import { emptyServiceResult } from '../../core/result.js';
5
+ import { EventChainViewer, eventDiagnostics, } from '../../event/service.js';
6
+ import { eventDirectionSchema, eventGraphReferenceSchema, eventImpactSubjectSchema, eventInspectModeSchema, eventProposedSourceSchema, eventRenderViewSchema, eventSelectorSchema, eventStateSubjectSchema, } from '../../schemas/event.js';
7
+ import { workspaceIdSchema } from '../../schemas/common.js';
8
+ import { compactValidatedInputSchema } from '../server/context-schemas.js';
9
+ import { nonNegativeIntegerSchema, sha256Schema } from '../server/output-schemas.js';
10
+ import { progressReporter } from '../server/progress.js';
11
+ import { errorResult, setInlineFilesScanned, strictOperationResultSchema, toolResult, } from '../server/result.js';
12
+ const nestedSelectorSchema = compactValidatedInputSchema(eventSelectorSchema, 'Event selector; see docs/events.md.');
13
+ const nestedStateSubjectSchema = compactValidatedInputSchema(eventStateSubjectSchema, 'State kind and name.');
14
+ const nestedImpactSubjectSchema = compactValidatedInputSchema(eventImpactSubjectSchema, 'Impact kind and name.');
15
+ const nestedGraphReferenceSchema = compactValidatedInputSchema(eventGraphReferenceSchema, 'Cached revision or graph artifact URI.');
16
+ const nestedProposedSourceSchema = compactValidatedInputSchema(eventProposedSourceSchema, 'In-memory source overlay; never written.');
17
+ const eventInspectInputSchema = z
18
+ .object({
19
+ workspaceId: workspaceIdSchema,
20
+ mode: eventInspectModeSchema,
21
+ selector: nestedSelectorSchema.optional(),
22
+ from: nestedSelectorSchema.optional(),
23
+ to: nestedSelectorSchema.optional(),
24
+ direction: eventDirectionSchema.optional(),
25
+ maxDepth: z.number().int().min(1).max(64).optional(),
26
+ maxNodes: z.number().int().min(1).max(5_000).optional(),
27
+ maxEdges: z.number().int().min(1).max(20_000).optional(),
28
+ expandHelpers: z.boolean().optional(),
29
+ stateSubject: nestedStateSubjectSchema.optional(),
30
+ impactSubject: nestedImpactSubjectSchema.optional(),
31
+ refresh: z.boolean().optional(),
32
+ })
33
+ .strict()
34
+ .superRefine((value, context) => {
35
+ if (value.mode === 'trace' && value.selector === undefined) {
36
+ context.addIssue({ code: 'custom', path: ['selector'], message: 'Trace requires selector' });
37
+ }
38
+ if (value.mode === 'explain_path' && (value.from === undefined || value.to === undefined)) {
39
+ context.addIssue({
40
+ code: 'custom',
41
+ path: value.from === undefined ? ['from'] : ['to'],
42
+ message: 'Path explanation requires from and to',
43
+ });
44
+ }
45
+ if (value.mode === 'impact' && value.impactSubject === undefined) {
46
+ context.addIssue({
47
+ code: 'custom',
48
+ path: ['impactSubject'],
49
+ message: 'Impact analysis requires impactSubject',
50
+ });
51
+ }
52
+ });
53
+ const eventRenderInputSchema = z
54
+ .object({
55
+ workspaceId: workspaceIdSchema,
56
+ view: eventRenderViewSchema,
57
+ selector: nestedSelectorSchema.optional(),
58
+ direction: eventDirectionSchema.optional(),
59
+ maxDepth: z.number().int().min(1).max(64).optional(),
60
+ maxNodes: z.number().int().min(1).max(240).optional(),
61
+ expandHelpers: z.boolean().optional(),
62
+ includeHtml: z.boolean().optional(),
63
+ refresh: z.boolean().optional(),
64
+ })
65
+ .strict();
66
+ const eventCompareInputSchema = z
67
+ .object({
68
+ workspaceId: workspaceIdSchema,
69
+ before: nestedGraphReferenceSchema.optional(),
70
+ after: nestedGraphReferenceSchema.optional(),
71
+ proposedSources: z.array(nestedProposedSourceSchema).min(1).max(64).optional(),
72
+ render: z.boolean().optional(),
73
+ maxRenderNodes: z.number().int().min(1).max(240).optional(),
74
+ refresh: z.boolean().optional(),
75
+ })
76
+ .strict()
77
+ .superRefine((value, context) => {
78
+ if (value.after !== undefined && value.proposedSources !== undefined) {
79
+ context.addIssue({
80
+ code: 'custom',
81
+ path: ['after'],
82
+ message: 'after and proposedSources are mutually exclusive',
83
+ });
84
+ }
85
+ });
86
+ const eventGraphCountsSchema = z
87
+ .object({
88
+ events: nonNegativeIntegerSchema,
89
+ options: nonNegativeIntegerSchema,
90
+ entries: nonNegativeIntegerSchema,
91
+ helpers: nonNegativeIntegerSchema,
92
+ unresolvedNodes: nonNegativeIntegerSchema,
93
+ terminals: nonNegativeIntegerSchema,
94
+ edges: nonNegativeIntegerSchema,
95
+ derivedEdges: nonNegativeIntegerSchema,
96
+ stateAccesses: nonNegativeIntegerSchema,
97
+ issues: nonNegativeIntegerSchema,
98
+ diagnostics: nonNegativeIntegerSchema,
99
+ blockingDiagnostics: nonNegativeIntegerSchema,
100
+ skippedSources: nonNegativeIntegerSchema,
101
+ artifacts: nonNegativeIntegerSchema,
102
+ })
103
+ .strict();
104
+ const inspectBoundarySchema = z
105
+ .object({
106
+ direction: eventDirectionSchema,
107
+ maxDepth: nonNegativeIntegerSchema,
108
+ maxNodes: nonNegativeIntegerSchema,
109
+ maxEdges: nonNegativeIntegerSchema,
110
+ expandHelpers: z.boolean(),
111
+ refresh: z.boolean(),
112
+ })
113
+ .strict();
114
+ const renderHashSchema = z
115
+ .object({
116
+ json: sha256Schema,
117
+ svg: sha256Schema,
118
+ png: sha256Schema,
119
+ html: sha256Schema.optional(),
120
+ })
121
+ .strict();
122
+ const eventInspectOutputSchema = strictOperationResultSchema(z
123
+ .object({
124
+ mode: eventInspectModeSchema,
125
+ revision: sha256Schema,
126
+ graphHash: sha256Schema,
127
+ counts: eventGraphCountsSchema,
128
+ boundary: inspectBoundarySchema,
129
+ })
130
+ .strict());
131
+ const eventRenderOutputSchema = strictOperationResultSchema(z
132
+ .object({
133
+ view: eventRenderViewSchema,
134
+ revision: sha256Schema,
135
+ graphHash: sha256Schema,
136
+ layoutHash: sha256Schema,
137
+ hashes: renderHashSchema,
138
+ counts: eventGraphCountsSchema.extend({
139
+ selectedNodes: nonNegativeIntegerSchema,
140
+ omittedNodes: nonNegativeIntegerSchema,
141
+ branchRenders: nonNegativeIntegerSchema,
142
+ }),
143
+ boundary: z
144
+ .object({
145
+ direction: eventDirectionSchema,
146
+ maxDepth: nonNegativeIntegerSchema,
147
+ maxNodes: nonNegativeIntegerSchema,
148
+ expandHelpers: z.boolean(),
149
+ includeHtml: z.boolean(),
150
+ refresh: z.boolean(),
151
+ })
152
+ .strict(),
153
+ })
154
+ .strict());
155
+ const eventCompareOutputSchema = strictOperationResultSchema(z
156
+ .object({
157
+ beforeRevision: sha256Schema,
158
+ afterRevision: sha256Schema,
159
+ beforeGraphHash: sha256Schema,
160
+ afterGraphHash: sha256Schema,
161
+ renderHashes: renderHashSchema.optional(),
162
+ counts: z
163
+ .object({
164
+ changes: nonNegativeIntegerSchema,
165
+ addedNodes: nonNegativeIntegerSchema,
166
+ removedNodes: nonNegativeIntegerSchema,
167
+ changedNodes: nonNegativeIntegerSchema,
168
+ addedEdges: nonNegativeIntegerSchema,
169
+ removedEdges: nonNegativeIntegerSchema,
170
+ changedEdges: nonNegativeIntegerSchema,
171
+ addedStateAccesses: nonNegativeIntegerSchema,
172
+ removedStateAccesses: nonNegativeIntegerSchema,
173
+ changedStateAccesses: nonNegativeIntegerSchema,
174
+ addedStateLinks: nonNegativeIntegerSchema,
175
+ removedStateLinks: nonNegativeIntegerSchema,
176
+ changedStateLinks: nonNegativeIntegerSchema,
177
+ addedDiagnostics: nonNegativeIntegerSchema,
178
+ resolvedDiagnostics: nonNegativeIntegerSchema,
179
+ addedUnresolved: nonNegativeIntegerSchema,
180
+ resolvedUnresolved: nonNegativeIntegerSchema,
181
+ disconnectedRoots: nonNegativeIntegerSchema,
182
+ disconnectedBranches: nonNegativeIntegerSchema,
183
+ disconnectedTerminals: nonNegativeIntegerSchema,
184
+ beforeSkippedSources: nonNegativeIntegerSchema,
185
+ afterSkippedSources: nonNegativeIntegerSchema,
186
+ artifacts: nonNegativeIntegerSchema,
187
+ })
188
+ .strict(),
189
+ boundary: z
190
+ .object({
191
+ proposedSources: nonNegativeIntegerSchema,
192
+ render: z.boolean(),
193
+ maxRenderNodes: nonNegativeIntegerSchema,
194
+ refresh: z.boolean(),
195
+ })
196
+ .strict(),
197
+ })
198
+ .strict());
199
+ const readOnlyEventTool = {
200
+ readOnlyHint: true,
201
+ destructiveHint: false,
202
+ idempotentHint: true,
203
+ openWorldHint: false,
204
+ };
205
+ function graphCounts(graph, artifacts) {
206
+ const diagnostics = eventDiagnostics(graph);
207
+ return {
208
+ events: graph.statistics.eventCount,
209
+ options: graph.statistics.optionCount,
210
+ entries: graph.statistics.entryCount,
211
+ helpers: graph.statistics.helperCount,
212
+ unresolvedNodes: graph.statistics.unresolvedNodeCount,
213
+ terminals: graph.statistics.terminalCount,
214
+ edges: graph.statistics.edgeCount,
215
+ derivedEdges: graph.statistics.derivedEdgeCount,
216
+ stateAccesses: graph.statistics.stateAccessCount,
217
+ issues: graph.statistics.issueCount,
218
+ diagnostics: diagnostics.length,
219
+ blockingDiagnostics: diagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker').length,
220
+ skippedSources: graph.skippedSourceCount,
221
+ artifacts,
222
+ };
223
+ }
224
+ function graphValidation(graph) {
225
+ const counts = graphCounts(graph, 0);
226
+ return {
227
+ passed: graph.complete && counts.blockingDiagnostics === 0,
228
+ checks: [
229
+ {
230
+ id: 'event-analysis',
231
+ passed: graph.complete && counts.blockingDiagnostics === 0,
232
+ message: graph.complete
233
+ ? `${counts.blockingDiagnostics} blocking event-chain diagnostics; full evidence is linked`
234
+ : `${graph.skippedSourceCount} event-analysis source(s) were skipped; full evidence is linked`,
235
+ },
236
+ ],
237
+ };
238
+ }
239
+ function inspectRequest(input, context, signal) {
240
+ return {
241
+ workspaceId: input.workspaceId,
242
+ mode: input.mode,
243
+ ...(input.selector === undefined
244
+ ? {}
245
+ : { selector: input.selector }),
246
+ ...(input.from === undefined
247
+ ? {}
248
+ : { from: input.from }),
249
+ ...(input.to === undefined ? {} : { to: input.to }),
250
+ ...(input.direction === undefined ? {} : { direction: input.direction }),
251
+ ...(input.maxDepth === undefined ? {} : { maxDepth: input.maxDepth }),
252
+ ...(input.maxNodes === undefined ? {} : { maxNodes: input.maxNodes }),
253
+ ...(input.maxEdges === undefined ? {} : { maxEdges: input.maxEdges }),
254
+ ...(input.expandHelpers === undefined ? {} : { expandHelpers: input.expandHelpers }),
255
+ ...(input.stateSubject === undefined ? {} : { stateSubject: input.stateSubject }),
256
+ ...(input.impactSubject === undefined ? {} : { impactSubject: input.impactSubject }),
257
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
258
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
259
+ signal,
260
+ };
261
+ }
262
+ function renderRequest(input, context, signal) {
263
+ return {
264
+ workspaceId: input.workspaceId,
265
+ view: input.view,
266
+ ...(input.selector === undefined
267
+ ? {}
268
+ : { selector: input.selector }),
269
+ ...(input.direction === undefined ? {} : { direction: input.direction }),
270
+ ...(input.maxDepth === undefined ? {} : { maxDepth: input.maxDepth }),
271
+ ...(input.maxNodes === undefined ? {} : { maxNodes: input.maxNodes }),
272
+ ...(input.expandHelpers === undefined ? {} : { expandHelpers: input.expandHelpers }),
273
+ ...(input.includeHtml === undefined ? {} : { includeHtml: input.includeHtml }),
274
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
275
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
276
+ signal,
277
+ };
278
+ }
279
+ function compareRequest(input, context, signal) {
280
+ return {
281
+ workspaceId: input.workspaceId,
282
+ ...(input.before === undefined
283
+ ? {}
284
+ : { before: input.before }),
285
+ ...(input.after === undefined
286
+ ? {}
287
+ : { after: input.after }),
288
+ ...(input.proposedSources === undefined
289
+ ? {}
290
+ : {
291
+ proposedSources: input.proposedSources,
292
+ }),
293
+ ...(input.render === undefined ? {} : { render: input.render }),
294
+ ...(input.maxRenderNodes === undefined ? {} : { maxRenderNodes: input.maxRenderNodes }),
295
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
296
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
297
+ signal,
298
+ };
299
+ }
300
+ export function registerEventTools(server, engine, context) {
301
+ const viewer = new EventChainViewer(engine);
302
+ server.registerTool('hoi4.event_inspect', {
303
+ title: 'Inspect event chains',
304
+ description: 'Scan, find roots, trace, explain paths, inspect state flow, lint, or assess impact. Full source-linked reports are resources.',
305
+ inputSchema: eventInspectInputSchema,
306
+ outputSchema: eventInspectOutputSchema,
307
+ annotations: readOnlyEventTool,
308
+ }, async (input, extra) => {
309
+ const { workspaceId } = input;
310
+ try {
311
+ const progress = progressReporter(extra);
312
+ await progress.report(0, 3, 'Analyzing event chain');
313
+ const inspected = await viewer.inspect(inspectRequest(input, context, progress.signal));
314
+ await progress.report(2, 3, 'Linking complete event analysis');
315
+ const result = emptyServiceResult(workspaceId, {
316
+ mode: inspected.mode,
317
+ revision: inspected.graph.revision,
318
+ graphHash: inspected.graphHash,
319
+ counts: graphCounts(inspected.graph, inspected.artifacts.length),
320
+ boundary: {
321
+ direction: input.direction ?? 'both',
322
+ maxDepth: input.maxDepth ?? 8,
323
+ maxNodes: input.maxNodes ?? 500,
324
+ maxEdges: input.maxEdges ?? 2_000,
325
+ expandHelpers: input.expandHelpers ?? (input.mode === 'explain_path' ? true : false),
326
+ refresh: input.refresh ?? input.mode === 'scan',
327
+ },
328
+ });
329
+ result.code = inspected.graph.complete ? 'EVENT_INSPECTED' : 'EVENT_INSPECTED_PARTIAL';
330
+ setInlineFilesScanned(result, inspected.graph.filesScanned);
331
+ result.artifacts = inspected.artifacts.map(publicArtifactLink);
332
+ result.validation = graphValidation(inspected.graph);
333
+ await progress.report(3, 3, 'Event inspection complete');
334
+ return toolResult(result);
335
+ }
336
+ catch (error) {
337
+ return errorResult(error, workspaceId);
338
+ }
339
+ });
340
+ server.registerTool('hoi4.event_render', {
341
+ title: 'Render event chains',
342
+ description: 'Render deterministic JSON, SVG, PNG, and optional HTML for an event-chain view. Complete artifacts retain source links.',
343
+ inputSchema: eventRenderInputSchema,
344
+ outputSchema: eventRenderOutputSchema,
345
+ annotations: readOnlyEventTool,
346
+ }, async (input, extra) => {
347
+ const { workspaceId } = input;
348
+ try {
349
+ const progress = progressReporter(extra);
350
+ await progress.report(0, 3, 'Rendering event-chain view');
351
+ const rendered = await viewer.renderAndStore(renderRequest(input, context, progress.signal));
352
+ await progress.report(2, 3, 'Linking complete event renders');
353
+ const result = emptyServiceResult(workspaceId, {
354
+ view: rendered.render.view,
355
+ revision: rendered.graph.revision,
356
+ graphHash: rendered.graphHash,
357
+ layoutHash: rendered.render.layout.layoutHash,
358
+ hashes: rendered.render.hashes,
359
+ counts: {
360
+ ...graphCounts(rendered.graph, rendered.artifacts.length),
361
+ selectedNodes: rendered.render.selectedNodeIds.length,
362
+ omittedNodes: rendered.render.omittedNodeCount,
363
+ branchRenders: rendered.branches.length,
364
+ },
365
+ boundary: {
366
+ direction: input.direction ?? 'both',
367
+ maxDepth: input.maxDepth ?? 4,
368
+ maxNodes: input.maxNodes ?? 120,
369
+ expandHelpers: input.expandHelpers ?? false,
370
+ includeHtml: input.includeHtml ?? false,
371
+ refresh: input.refresh ?? false,
372
+ },
373
+ });
374
+ result.code = rendered.graph.complete ? 'EVENT_RENDERED' : 'EVENT_RENDERED_PARTIAL';
375
+ setInlineFilesScanned(result, rendered.graph.filesScanned);
376
+ result.artifacts = rendered.artifacts.map(publicArtifactLink);
377
+ result.validation = graphValidation(rendered.graph);
378
+ await progress.report(3, 3, 'Event render complete');
379
+ return toolResult(result);
380
+ }
381
+ catch (error) {
382
+ return errorResult(error, workspaceId);
383
+ }
384
+ });
385
+ server.registerTool('hoi4.event_compare', {
386
+ title: 'Compare event chains',
387
+ description: 'Compare cached, artifact-backed, current, or in-memory proposed event graphs without writing source. Full changes are resources.',
388
+ inputSchema: eventCompareInputSchema,
389
+ outputSchema: eventCompareOutputSchema,
390
+ annotations: readOnlyEventTool,
391
+ }, async (input, extra) => {
392
+ const { workspaceId } = input;
393
+ try {
394
+ const progress = progressReporter(extra);
395
+ await progress.report(0, 3, 'Comparing event-chain graphs');
396
+ const compared = await viewer.compareAndStore(compareRequest(input, context, progress.signal));
397
+ await progress.report(2, 3, 'Linking complete event comparison');
398
+ const { comparison } = compared;
399
+ const result = emptyServiceResult(workspaceId, {
400
+ beforeRevision: comparison.beforeRevision,
401
+ afterRevision: comparison.afterRevision,
402
+ beforeGraphHash: comparison.beforeGraphHash,
403
+ afterGraphHash: comparison.afterGraphHash,
404
+ ...(compared.render === undefined ? {} : { renderHashes: compared.render.hashes }),
405
+ counts: {
406
+ changes: comparison.changes.length,
407
+ addedNodes: comparison.addedNodeIds.length,
408
+ removedNodes: comparison.removedNodeIds.length,
409
+ changedNodes: comparison.changedNodeIds.length,
410
+ addedEdges: comparison.addedEdgeIds.length,
411
+ removedEdges: comparison.removedEdgeIds.length,
412
+ changedEdges: comparison.changedEdgeIds.length,
413
+ addedStateAccesses: comparison.addedStateAccessIds.length,
414
+ removedStateAccesses: comparison.removedStateAccessIds.length,
415
+ changedStateAccesses: comparison.changedStateAccessIds.length,
416
+ addedStateLinks: comparison.addedStateLinkIds.length,
417
+ removedStateLinks: comparison.removedStateLinkIds.length,
418
+ changedStateLinks: comparison.changedStateLinkIds.length,
419
+ addedDiagnostics: comparison.addedIssueIds.length,
420
+ resolvedDiagnostics: comparison.resolvedIssueIds.length,
421
+ addedUnresolved: comparison.addedUnresolvedIds.length,
422
+ resolvedUnresolved: comparison.resolvedUnresolvedIds.length,
423
+ disconnectedRoots: comparison.newlyDisconnectedRootIds.length,
424
+ disconnectedBranches: comparison.newlyDisconnectedBranchIds.length,
425
+ disconnectedTerminals: comparison.newlyDisconnectedTerminalIds.length,
426
+ beforeSkippedSources: compared.before.skippedSourceCount,
427
+ afterSkippedSources: compared.after.skippedSourceCount,
428
+ artifacts: compared.artifacts.length,
429
+ },
430
+ boundary: {
431
+ proposedSources: input.proposedSources?.length ?? 0,
432
+ render: input.render ?? true,
433
+ maxRenderNodes: input.maxRenderNodes ?? 120,
434
+ refresh: compared.refresh,
435
+ },
436
+ });
437
+ result.code =
438
+ compared.before.complete && compared.after.complete
439
+ ? 'EVENT_COMPARED'
440
+ : 'EVENT_COMPARED_PARTIAL';
441
+ setInlineFilesScanned(result, [...new Set([...compared.before.filesScanned, ...compared.after.filesScanned])].sort(compareCodeUnits));
442
+ result.artifacts = compared.artifacts.map(publicArtifactLink);
443
+ const beforeValidation = graphValidation(compared.before);
444
+ const afterValidation = graphValidation(compared.after);
445
+ result.validation = {
446
+ passed: beforeValidation.passed && afterValidation.passed,
447
+ checks: [...beforeValidation.checks, ...afterValidation.checks].map((check, index) => ({
448
+ ...check,
449
+ id: `${index === 0 ? 'before' : 'after'}-${check.id}`,
450
+ })),
451
+ };
452
+ await progress.report(3, 3, 'Event comparison complete');
453
+ return toolResult(result);
454
+ }
455
+ catch (error) {
456
+ return errorResult(error, workspaceId);
457
+ }
458
+ });
459
+ }
@@ -6,14 +6,15 @@ import { RenderBudget } from '../../core/render-budget.js';
6
6
  import { emptyServiceResult, ServiceError } from '../../core/result.js';
7
7
  import { parseClausewitz } from '../../core/source/index.js';
8
8
  import { readDependenciesFromScannedFiles } from '../../core/transactions.js';
9
- import { FocusWorkbench, FOCUS_RENDER_MAX_OUTPUT_SCALE, FOCUS_RENDER_MIN_OUTPUT_SCALE, focusPlanHash, focusPresentationEvidence, focusPlanningSidecarPath, importContinuousFocusPalettes, importFocusTrees, linkContinuousFocusPalettes, parseFocusPlanningSidecar, renderContinuousFocusPalette, renderFocusTree, resolveFocusPresentation, enrichFocusPlanFromSidecar, } from '../../focus/index.js';
10
- import { continuousFocusPaletteSchema, focusLayoutSchema, focusTreePlanSchema, } from '../../schemas/focus.js';
9
+ import { FocusWorkbench, FOCUS_RENDER_MAX_OUTPUT_SCALE, FOCUS_RENDER_MIN_OUTPUT_SCALE, assertCompactLayoutQuality, compactFocusTreePlanAsync, focusPlanHash, focusPresentationEvidence, focusPlanningSidecarPath, importContinuousFocusPalettes, importFocusTrees, linkContinuousFocusPalettes, parseFocusPlanningSidecar, renderContinuousFocusPalette, renderFocusTree, resolveFocusPresentation, enrichFocusPlanFromSidecar, } from '../../focus/index.js';
10
+ import { continuousFocusPaletteSchema, focusLayoutMetricsSchema, focusLayoutSchema, focusTreePlanSchema, } from '../../schemas/focus.js';
11
11
  import { workspaceIdSchema, workspaceRelativePathSchema } from '../../schemas/common.js';
12
12
  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
16
  import { requireServerScope } from '../server/base-tools.js';
17
+ import { compactValidatedInputSchema } from '../server/context-schemas.js';
17
18
  import { autonomousFailureContext, autonomousResultArtifacts, executePlannedTransaction, } from '../server/transaction-execution.js';
18
19
  const focusInspectOutputSchema = strictOperationResultSchema(z
19
20
  .object({
@@ -33,6 +34,7 @@ const focusInspectOutputSchema = strictOperationResultSchema(z
33
34
  resolvedTitleCount: nonNegativeIntegerSchema,
34
35
  layoutHash: sha256Schema,
35
36
  layoutDecisionCount: nonNegativeIntegerSchema,
37
+ layoutMetrics: focusLayoutMetricsSchema,
36
38
  diagnosticCount: nonNegativeIntegerSchema,
37
39
  })
38
40
  .strict())
@@ -114,6 +116,7 @@ const focusPlanOutputSchema = strictOperationResultSchema(z.discriminatedUnion('
114
116
  })
115
117
  .strict(),
116
118
  ]));
119
+ const compactFocusPlanSchema = compactValidatedInputSchema(z.union([focusTreePlanSchema, continuousFocusPaletteSchema]), `Complete focus plan: https://github.com/klimPaskov/hoi4-agent-tools/blob/v${PACKAGE_VERSION}/docs/focus.md`);
117
120
  const focusInspectInput = z
118
121
  .object({
119
122
  mode: z.enum(['national', 'continuous']).optional(),
@@ -208,7 +211,9 @@ const focusPlanInput = z
208
211
  mode: z.enum(['national', 'continuous']).optional(),
209
212
  workspaceId: workspaceIdSchema,
210
213
  relativePath: workspaceRelativePathSchema,
211
- plan: z.union([focusTreePlanSchema, continuousFocusPaletteSchema]),
214
+ treeId: z.string().min(1).max(256).optional(),
215
+ layoutMode: z.enum(['authored', 'compact']).default('authored'),
216
+ plan: compactFocusPlanSchema.optional(),
212
217
  createIfMissing: z.boolean().default(false),
213
218
  horizontalSpacing: z.number().int().min(80).max(1000).optional(),
214
219
  verticalSpacing: z.number().int().min(60).max(1000).optional(),
@@ -223,13 +228,31 @@ const focusPlanInput = z
223
228
  .superRefine((value, context) => {
224
229
  const mode = value.mode ?? 'national';
225
230
  const schema = mode === 'continuous' ? continuousFocusPaletteSchema : focusTreePlanSchema;
226
- if (!schema.safeParse(value.plan).success)
231
+ if (value.plan !== undefined && !schema.safeParse(value.plan).success)
227
232
  context.addIssue({
228
233
  code: 'custom',
229
234
  path: ['plan'],
230
235
  message: `plan must be a ${mode} focus plan`,
231
236
  });
232
237
  if (mode === 'continuous') {
238
+ if (value.plan === undefined)
239
+ context.addIssue({
240
+ code: 'custom',
241
+ path: ['plan'],
242
+ message: 'plan is required in continuous mode',
243
+ });
244
+ if (value.treeId !== undefined)
245
+ context.addIssue({
246
+ code: 'custom',
247
+ path: ['treeId'],
248
+ message: 'treeId is only valid in national mode',
249
+ });
250
+ if (value.layoutMode !== 'authored')
251
+ context.addIssue({
252
+ code: 'custom',
253
+ path: ['layoutMode'],
254
+ message: 'compact layout mode is only valid in national mode',
255
+ });
233
256
  for (const [field, fieldValue] of [
234
257
  ['horizontalSpacing', value.horizontalSpacing],
235
258
  ['verticalSpacing', value.verticalSpacing],
@@ -244,6 +267,35 @@ const focusPlanInput = z
244
267
  });
245
268
  }
246
269
  }
270
+ else {
271
+ if (value.plan === undefined && value.layoutMode !== 'compact')
272
+ context.addIssue({
273
+ code: 'custom',
274
+ path: ['plan'],
275
+ message: 'plan is required unless layoutMode is compact',
276
+ });
277
+ if (value.plan === undefined && value.treeId === undefined)
278
+ context.addIssue({
279
+ code: 'custom',
280
+ path: ['treeId'],
281
+ message: 'treeId is required for plan-free compact reflow',
282
+ });
283
+ if (value.plan === undefined && value.createIfMissing)
284
+ context.addIssue({
285
+ code: 'custom',
286
+ path: ['createIfMissing'],
287
+ message: 'plan-free compact reflow requires an existing focus source',
288
+ });
289
+ if (value.plan !== undefined &&
290
+ value.treeId !== undefined &&
291
+ 'id' in value.plan &&
292
+ value.plan.id !== value.treeId)
293
+ context.addIssue({
294
+ code: 'custom',
295
+ path: ['treeId'],
296
+ message: 'treeId must match the supplied national focus plan',
297
+ });
298
+ }
247
299
  });
248
300
  const artifactProducing = {
249
301
  readOnlyHint: false,
@@ -584,6 +636,7 @@ export function registerFocusTools(server, engine, context) {
584
636
  resolvedTitleCount: plan.focuses.filter(({ id }) => presentation.entries[id]?.titleSourceLocation !== undefined).length,
585
637
  layoutHash: layout.layoutHash,
586
638
  layoutDecisionCount: layout.decisions.length,
639
+ layoutMetrics: layout.metrics,
587
640
  diagnosticCount,
588
641
  })),
589
642
  palettes: [],
@@ -727,7 +780,7 @@ export function registerFocusTools(server, engine, context) {
727
780
  });
728
781
  server.registerTool('hoi4.focus_rewrite', {
729
782
  title: 'Create or clean up focus content',
730
- description: 'Create or clean up a national tree or continuous palette from a complete plan, validate the result, apply it in one call, and return before-and-after review artifacts. Omit mode for national behavior. National plans accept bounded horizontalSpacing, verticalSpacing, padding, and reviewScale controls. Set createIfMissing for a new file.',
783
+ description: 'Create or clean up a national tree or continuous palette, validate it, and apply it in one call. Supply a complete plan; set layoutMode compact for automatic arrangement. Existing national trees can omit the plan and use treeId plus layoutMode compact. Set createIfMissing for a new file.',
731
784
  inputSchema: focusPlanInput,
732
785
  outputSchema: focusPlanOutputSchema,
733
786
  annotations: {
@@ -901,7 +954,7 @@ export function registerFocusTools(server, engine, context) {
901
954
  : 'Continuous focus rewrite blocked');
902
955
  return toolResult(result);
903
956
  }
904
- const plan = input.plan;
957
+ const suppliedPlan = input.plan;
905
958
  if (createIfMissing &&
906
959
  (!relativePath.toLowerCase().endsWith('.txt') ||
907
960
  !underConfiguredRoot(relativePath, workspace.registration.roots.focus)))
@@ -919,6 +972,30 @@ export function registerFocusTools(server, engine, context) {
919
972
  const paletteImport = continuousPalettes(snapshot);
920
973
  if (imported !== undefined)
921
974
  imported.result.plans = imported.result.plans.map((current) => linkContinuousFocusPalettes(current, paletteImport.palettes));
975
+ let compactPlanning;
976
+ let compactSourcePlan;
977
+ let plan;
978
+ if (suppliedPlan !== undefined) {
979
+ if (input.layoutMode === 'compact') {
980
+ compactSourcePlan = suppliedPlan;
981
+ compactPlanning = await compactFocusTreePlanAsync(suppliedPlan, {
982
+ signal: progress.signal,
983
+ });
984
+ plan = compactPlanning.plan;
985
+ }
986
+ else {
987
+ plan = suppliedPlan;
988
+ }
989
+ }
990
+ else {
991
+ if (imported === undefined)
992
+ throw new ServiceError('FOCUS_COMPACT_SOURCE_REQUIRED', 'Plan-free compact reflow requires an existing national focus source', { relativePath });
993
+ compactSourcePlan = selectPlan(imported.result, input.treeId);
994
+ compactPlanning = await compactFocusTreePlanAsync(compactSourcePlan, {
995
+ signal: progress.signal,
996
+ });
997
+ plan = compactPlanning.plan;
998
+ }
922
999
  const currentPlan = imported?.result.plans.find(({ id }) => id === plan.id);
923
1000
  if (currentPlan === undefined && !createIfMissing) {
924
1001
  if (imported === undefined)
@@ -954,7 +1031,9 @@ export function registerFocusTools(server, engine, context) {
954
1031
  });
955
1032
  const currentLayout = currentPlan === undefined
956
1033
  ? undefined
957
- : await workbench.layoutAsync(currentPlan, { signal: progress.signal });
1034
+ : currentPlan === compactSourcePlan && compactPlanning !== undefined
1035
+ ? compactPlanning.currentLayout
1036
+ : await workbench.layoutAsync(currentPlan, { signal: progress.signal });
958
1037
  const currentDiagnostics = currentPlan === undefined || currentLayout === undefined
959
1038
  ? []
960
1039
  : workbench.lint(currentPlan, {
@@ -980,6 +1059,11 @@ export function registerFocusTools(server, engine, context) {
980
1059
  importedPlanHash: proposedPlanHash,
981
1060
  },
982
1061
  };
1062
+ const proposedLayout = compactPlanning?.proposedLayout ??
1063
+ (await workbench.layoutAsync(reviewPlan, { signal: progress.signal }));
1064
+ if (input.layoutMode === 'compact') {
1065
+ assertCompactLayoutQuality(currentLayout, proposedLayout);
1066
+ }
983
1067
  const proposed = await workbench.renderAndStore(workspaceId, reviewPlan, {
984
1068
  ...(context.principal === undefined ? {} : { principal: context.principal }),
985
1069
  index: snapshot.index,
@@ -994,6 +1078,7 @@ export function registerFocusTools(server, engine, context) {
994
1078
  },
995
1079
  budget: renderBudget,
996
1080
  signal: progress.signal,
1081
+ layout: proposedLayout,
997
1082
  });
998
1083
  const beforePng = currentRender?.png ??
999
1084
  (await createTransparentPng(proposed.bundle.width, proposed.bundle.height, 'new national focus baseline', renderBudget));
@@ -1043,6 +1128,7 @@ export function registerFocusTools(server, engine, context) {
1043
1128
  plan,
1044
1129
  createIfMissing,
1045
1130
  authority: 'plan',
1131
+ layout: proposed.layout,
1046
1132
  ...(context.principal === undefined ? {} : { principal: context.principal }),
1047
1133
  index: snapshot.index,
1048
1134
  references: catalog,