hoi4-agent-tools 3.3.0 → 3.4.1

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 (89) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/hoi4_agent_tools/core/condition-evaluator.js +20 -8
  3. package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
  4. package/dist/hoi4_agent_tools/core/index.js +1 -0
  5. package/dist/hoi4_agent_tools/core/job-worker-host.js +19 -1
  6. package/dist/hoi4_agent_tools/core/operation-result.js +4 -1
  7. package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
  8. package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
  9. package/dist/hoi4_agent_tools/event/operations.js +32 -10
  10. package/dist/hoi4_agent_tools/event/service.js +21 -2
  11. package/dist/hoi4_agent_tools/focus/compact.js +241 -27
  12. package/dist/hoi4_agent_tools/focus/layout.js +119 -8
  13. package/dist/hoi4_agent_tools/focus/render.js +62 -0
  14. package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
  15. package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
  16. package/dist/hoi4_agent_tools/gui/assets.js +54 -4
  17. package/dist/hoi4_agent_tools/gui/layout.js +935 -104
  18. package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
  19. package/dist/hoi4_agent_tools/gui/shader.js +515 -0
  20. package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
  21. package/dist/hoi4_agent_tools/gui/studio.js +245 -19
  22. package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
  23. package/dist/hoi4_agent_tools/gui/validators.js +41 -12
  24. package/dist/hoi4_agent_tools/map/operations.js +70 -1
  25. package/dist/hoi4_agent_tools/map/render.js +70 -1
  26. package/dist/hoi4_agent_tools/map/service.js +30 -6
  27. package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
  28. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
  29. package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
  30. package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
  31. package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
  32. package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
  33. package/dist/hoi4_agent_tools/mechanic/interpreter.js +91 -30
  34. package/dist/hoi4_agent_tools/mechanic/source.js +19 -4
  35. package/dist/hoi4_agent_tools/probability/evaluation.js +3 -0
  36. package/dist/hoi4_agent_tools/probability/operations.js +8 -0
  37. package/dist/hoi4_agent_tools/probability/service.js +33 -12
  38. package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
  39. package/dist/hoi4_agent_tools/schemas/event.js +8 -0
  40. package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
  41. package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
  42. package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
  43. package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
  44. package/dist/hoi4_agent_tools/technology/graph.js +25 -10
  45. package/dist/hoi4_agent_tools/technology/operations.js +29 -1
  46. package/dist/hoi4_agent_tools/technology/render.js +287 -31
  47. package/dist/hoi4_agent_tools/technology/service.js +56 -8
  48. package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
  49. package/dist/hoi4_agent_tools/version.js +1 -1
  50. package/docs/events.md +4 -2
  51. package/docs/focus.md +10 -0
  52. package/docs/gui.md +50 -3
  53. package/docs/jobs.md +1 -0
  54. package/docs/map.md +8 -0
  55. package/docs/mechanics.md +2 -1
  56. package/docs/probability.md +3 -1
  57. package/docs/technology.md +11 -2
  58. package/package.json +1 -1
  59. package/schemas/configuration.schema.json +1 -1
  60. package/schemas/continuous-focus-palette.schema.json +1 -1
  61. package/schemas/custom-weighted-pool.schema.json +1 -1
  62. package/schemas/decision-inspect-input.schema.json +1 -1
  63. package/schemas/event-feature-manifest.schema.json +1 -1
  64. package/schemas/focus-plan.schema.json +1 -1
  65. package/schemas/focus-planning-sidecar.schema.json +1 -1
  66. package/schemas/gui-animation-source.schema.json +1 -1
  67. package/schemas/gui-helper.schema.json +1 -1
  68. package/schemas/gui-scenario.schema.json +1 -1
  69. package/schemas/helper-expansion-request.schema.json +1 -1
  70. package/schemas/helper-expansion-summary.schema.json +1 -1
  71. package/schemas/impact-inspect-input.schema.json +1 -1
  72. package/schemas/map-operation.schema.json +1 -1
  73. package/schemas/mechanic-test-input.schema.json +1 -1
  74. package/schemas/operation-result.schema.json +1 -1
  75. package/schemas/package-check-input.schema.json +1 -1
  76. package/schemas/package-manifest.schema.json +1 -1
  77. package/schemas/probability-analysis-result.schema.json +1 -1
  78. package/schemas/probability-compare-input.schema.json +1 -1
  79. package/schemas/probability-evaluate-input.schema.json +1 -1
  80. package/schemas/probability-inspect-input.schema.json +1 -1
  81. package/schemas/probability-render-input.schema.json +1 -1
  82. package/schemas/probability-scenario-set.schema.json +1 -1
  83. package/schemas/probability-sequence-input.schema.json +1 -1
  84. package/schemas/probability-simulate-input.schema.json +1 -1
  85. package/schemas/probability-sweep-input.schema.json +1 -1
  86. package/schemas/scenario-suite.schema.json +1 -1
  87. package/schemas/scenario-test-input.schema.json +1 -1
  88. package/schemas/shared-scenario.schema.json +1 -1
  89. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.4.1 - 2026-09-25
6
+
7
+ - Validate technology renders against the requested view's source placement, node coverage, and sprite resolution without inheriting unrelated graph-wide diagnostics, while reporting deferred whole-workspace helper projection as a separate analysis boundary; resolve subtechnology art from the source GUI slot before using a technology-id fallback and include every referenced slot texture in the asset scan.
8
+ - Accept focused event inspection and render evidence when every event source was scanned and no blocking diagnostics remain, while retaining deferred workspace-wide helper and lifecycle projections as an explicit boundary.
9
+ - Keep repeated window-local GUI control names additive in the shared index, exclude engine-owned compound-control roles from scripted click-effect requirements, and preserve compact operation summaries when linked evidence exceeds the MCP wire budget.
10
+ - Parse each active scripted-effect source document once when building mechanic helper inventories so bounded real-workspace scenarios do not multiply parser state by the number of helpers in a file.
11
+ - Interpret declared temporary-variable assignment, arithmetic, rounding, clamping, and scope-valued inputs with chain-wide unscoped semantics in bounded mechanic scenarios.
12
+ - Evaluate `has_variable` against declared scenario state so guarded defaults and subsequent temporary-variable arithmetic remain deterministic.
13
+ - Follow a live replacement worker through its terminal publication instead of returning a nonterminal job record during concurrent checkpoint recovery.
14
+
15
+ ## 3.4.0 - 2026-09-20
16
+
17
+ - Add branch-local focus cleanup with pinned anchors and symmetry groups, connector measurements, source-linked crops, and scenario-backed structural route diagnostics.
18
+ - Report GUI visibility and enabled branches, measured label and background boxes, and matched source-baseline renders alongside scenario comparisons.
19
+ - Expose complete source-backed probability catalogs, actionable missing scenario inputs, and eligible, excluded, and unresolved inventories.
20
+ - Add selected event-chain comparisons and revision-bound bounded helper expansion while retaining explicit lifecycle coverage limits.
21
+ - Compose technology folders from source pixel placements, native item skins, icon and subtechnology positions, panel and background artwork, year labels, and supplied research scenarios; report unresolved placement and asset coverage honestly.
22
+ - Resolve source `GFX_*_medium` artwork and design-team overlays at native item size for chemical and biological warfare folders.
23
+ - Compose installed GUI backgrounds, focus inlays, container scrolling, native button and progress shaders, and scenario-driven rows from source assets and control states.
24
+ - Bound focus connector and overlap diagnostics on anchored trees with thousands of nodes.
25
+ - Add focused map lookups, reusable render tiles, affected-area output, and typed external script-reference updates for map renumbering.
26
+
5
27
  ## 3.3.0 - 2026-09-20
6
28
 
7
29
  - Add `hoi4.mechanic_test` with copied versioned scenario state, bounded source effects, explicit time advancement, traces, and typed gameplay assertions.
@@ -96,7 +96,7 @@ function numberValue(expression, scenario, context, definitions, path, seen = ne
96
96
  }
97
97
  return undefined;
98
98
  }
99
- function unresolved(assignment, candidate, reason) {
99
+ function unresolved(assignment, candidate, reason, scenarioInput) {
100
100
  return {
101
101
  state: 'unresolved',
102
102
  unresolved: [
@@ -104,6 +104,7 @@ function unresolved(assignment, candidate, reason) {
104
104
  code: 'TRIGGER_UNRESOLVED',
105
105
  message: reason,
106
106
  path: assignment.key.value,
107
+ ...(scenarioInput === undefined ? {} : { details: { scenarioInput } }),
107
108
  candidateId: candidate.id,
108
109
  ...(candidate.provenance[0] === undefined ? {} : { provenance: candidate.provenance[0] }),
109
110
  },
@@ -136,7 +137,11 @@ function evaluateCheckVariable(block, scenario, candidate, assignment, scopeCont
136
137
  const leftNumber = numberValue(variable, scenario, scopeContext, definitions, path);
137
138
  const rightNumber = numberValue(target, scenario, scopeContext, definitions, path);
138
139
  if (leftNumber === undefined || rightNumber === undefined)
139
- return unresolved(assignment, candidate, `Scenario does not resolve numeric operands ${variable} and ${target}`);
140
+ return unresolved(assignment, candidate, `Scenario does not resolve numeric operands ${variable} and ${target}`, leftNumber === undefined && !/^(?:@|constant:)/u.test(variable)
141
+ ? `variable.${variable}`
142
+ : rightNumber === undefined && !/^(?:@|constant:)/u.test(target)
143
+ ? `variable.${target}`
144
+ : undefined);
140
145
  const compareName = firstScalar(block, 'compare')?.value ??
141
146
  (short?.operator.text === '>'
142
147
  ? 'greater_than'
@@ -202,7 +207,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
202
207
  const array = scopeStateValue(scenario, `array.${arrayId}`, scopeContext) ??
203
208
  rootStateValue(scenario, `array.${arrayId}`);
204
209
  if (!Array.isArray(array))
205
- return unresolved(assignment, candidate, `Scenario does not declare array ${arrayId}`);
210
+ return unresolved(assignment, candidate, `Scenario does not declare array ${arrayId}`, `array.${arrayId}`);
206
211
  const compared = ['THIS', 'ROOT', 'PREV'].includes(declaredValue.toUpperCase()) ||
207
212
  resolveScopeContext(scenario, declaredValue, scopeContext) !== undefined
208
213
  ? scopeIdentity(resolveScopeContext(scenario, declaredValue, scopeContext) ?? scopeContext)
@@ -244,7 +249,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
244
249
  const declaredScope = bool(rootStateValue(scenario, `scope.${key}`));
245
250
  if (declaredScope !== undefined)
246
251
  return { state: declaredScope ? 'true' : 'false', unresolved: [] };
247
- return unresolved(assignment, candidate, `Scoped or compound trigger ${key} is not declared by the scenario`);
252
+ return unresolved(assignment, candidate, `Scoped or compound trigger ${key} is not declared by the scenario`, `scope.${key}`);
248
253
  }
249
254
  const right = assignment.value.value;
250
255
  if (key === 'always') {
@@ -263,7 +268,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
263
268
  ? scenario.flags
264
269
  : scopeContext.binding.flags;
265
270
  if (scenario.closedFlags === false && flags?.includes(right) !== true)
266
- return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`);
271
+ return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`, `flag.${right}`);
267
272
  return { state: flags?.includes(right) === true ? 'true' : 'false', unresolved: [] };
268
273
  }
269
274
  if (key === 'has_event_target') {
@@ -273,12 +278,19 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
273
278
  unresolved: [],
274
279
  };
275
280
  }
281
+ if (key === 'has_variable') {
282
+ const state = scopeContext.expression === 'ROOT' ? scenario.state : scopeContext.binding.state;
283
+ return {
284
+ state: Object.hasOwn(state, right) || Object.hasOwn(state, `variable.${right}`) ? 'true' : 'false',
285
+ unresolved: [],
286
+ };
287
+ }
276
288
  if (key === 'tag' || key === 'original_tag') {
277
289
  const actor = scopeContext.expression === 'ROOT'
278
290
  ? scenario.actor
279
291
  : (scopeContext.binding.actor ?? scopeContext.binding.id);
280
292
  if (actor === undefined)
281
- return unresolved(assignment, candidate, `Scenario does not declare actor for ${key}`);
293
+ return unresolved(assignment, candidate, `Scenario does not declare actor for ${key}`, 'actor');
282
294
  return {
283
295
  state: compare(actor, right, assignment.operator.text) ? 'true' : 'false',
284
296
  unresolved: [],
@@ -286,7 +298,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
286
298
  }
287
299
  if (key === 'date') {
288
300
  if (scenario.date === undefined)
289
- return unresolved(assignment, candidate, 'Scenario does not declare date');
301
+ return unresolved(assignment, candidate, 'Scenario does not declare date', 'date');
290
302
  const dateNumber = (value) => {
291
303
  const parts = /^(\d+)\.(\d{1,2})\.(\d{1,2})$/u.exec(value);
292
304
  if (parts === null)
@@ -336,7 +348,7 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
336
348
  const declared = scopeStateValue(scenario, key, scopeContext) ??
337
349
  numberValue(key, scenario, scopeContext, definitions, candidate.provenance[0]?.path);
338
350
  if (declared === undefined)
339
- return unresolved(assignment, candidate, `Scenario does not declare trigger ${key}`);
351
+ return unresolved(assignment, candidate, `Scenario does not declare trigger ${key}`, key);
340
352
  const expectedBoolean = bool(right);
341
353
  const actualBoolean = bool(declared);
342
354
  if (expectedBoolean !== undefined && actualBoolean !== undefined)
@@ -104,6 +104,9 @@ export async function inspectHelperExpansion(engine, workspaceId, inventory, req
104
104
  kind,
105
105
  rootId: input.roots[root],
106
106
  path: path.map((index) => inventory.edges[index].id),
107
+ ...(inventory.recordDetails === undefined
108
+ ? {}
109
+ : inventory.recordDetails(path.map((index) => inventory.edges[index]))),
107
110
  }));
108
111
  const continuationName = `${inventory.domain}-helper-continuation.json`;
109
112
  const report = {
@@ -1369,6 +1369,7 @@ export class SymbolIndex {
1369
1369
  this.#active.set(key, active);
1370
1370
  const additiveCategory = active.kind === 'decision_category' ||
1371
1371
  active.kind === 'technology_category' ||
1372
+ active.kind === 'gui_element' ||
1372
1373
  active.kind === 'texture';
1373
1374
  for (const candidate of group) {
1374
1375
  candidate.overridden = additiveCategory ? candidate.sourceShadowed : candidate !== active;
@@ -73,7 +73,12 @@ export class JobWorkerHost {
73
73
  for (;;) {
74
74
  try {
75
75
  await this.scheduler.run(this.owner, 1024, signal, () => this.capacity.run(signal, (lease) => this.launch(workspaceId, id, lease, principal)));
76
- return await this.jobs.get(workspaceId, id, principal);
76
+ const current = await this.jobs.get(workspaceId, id, principal);
77
+ if (['completed', 'cancelled', 'failed'].includes(current.status))
78
+ return current;
79
+ if (current.owner !== undefined && jobOwnerLiveness(current.owner) === 'alive')
80
+ return await this.followOwner(workspaceId, id, principal);
81
+ throw new ServiceError('JOB_WORKER_EXIT', 'The worker stopped without publishing a terminal job outcome');
77
82
  }
78
83
  catch (error) {
79
84
  const current = await this.jobs.get(workspaceId, id, principal);
@@ -108,6 +113,19 @@ export class JobWorkerHost {
108
113
  }
109
114
  }
110
115
  }
116
+ async followOwner(workspaceId, id, principal) {
117
+ for (;;) {
118
+ const record = await this.jobs.get(workspaceId, id, principal);
119
+ if (['completed', 'failed', 'cancelled'].includes(record.status))
120
+ return record;
121
+ const liveness = record.owner === undefined ? 'dead' : jobOwnerLiveness(record.owner);
122
+ if (liveness !== 'alive')
123
+ throw new ServiceError(liveness === 'unknown' ? 'JOB_OWNER_UNRESOLVED' : 'JOB_WORKER_EXIT', liveness === 'unknown'
124
+ ? 'The replacement execution owner cannot be verified on this host'
125
+ : 'The replacement worker stopped without publishing a terminal job outcome');
126
+ await delay(100);
127
+ }
128
+ }
111
129
  async launch(workspaceId, id, lease, principal) {
112
130
  // Resolve the same configured workspace and grants again immediately before dispatch.
113
131
  await this.jobs.get(workspaceId, id, principal);
@@ -114,6 +114,9 @@ function boundedResult(result, actualBytes) {
114
114
  : 'Tool output exceeded the wire budget; nonessential inline details were omitted',
115
115
  details: { actualBytes, maxBytes: MAX_TOOL_RESULT_BYTES },
116
116
  };
117
+ const compactData = result.data !== null && typeof result.data === 'object' && !Array.isArray(result.data)
118
+ ? result.data
119
+ : {};
117
120
  return {
118
121
  status: result.status,
119
122
  code: result.code.slice(0, 256),
@@ -141,7 +144,7 @@ function boundedResult(result, actualBytes) {
141
144
  },
142
145
  },
143
146
  ],
144
- data: {},
147
+ data: jsonBytesWithin(compactData, 2_048) ? compactData : {},
145
148
  };
146
149
  }
147
150
  function jsonStringBytes(value) {
@@ -0,0 +1,78 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ import { traceEventGraph } from './algorithms.js';
3
+ import { selectEventNodes } from './queries.js';
4
+ /** Project a whole-graph comparison onto the downstream chain in each revision. */
5
+ export function selectEventComparison(before, after, comparison, selector, maxChainNodes, signal) {
6
+ const trace = (graph) => {
7
+ const starts = selectEventNodes(graph, selector, signal).map(({ id }) => id);
8
+ return traceEventGraph(graph, starts, {
9
+ maxDepth: 64,
10
+ maxNodes: maxChainNodes,
11
+ maxEdges: Math.max(1, Math.min(20_000, graph.edges.length)),
12
+ direction: 'downstream',
13
+ expandHelpers: true,
14
+ }, signal);
15
+ };
16
+ const beforeTrace = trace(before);
17
+ const afterTrace = trace(after);
18
+ const beforeNodeIds = beforeTrace.nodes.map(({ id }) => id).sort(compareCodeUnits);
19
+ const afterNodeIds = afterTrace.nodes.map(({ id }) => id).sort(compareCodeUnits);
20
+ const selectedNodes = new Set([...beforeNodeIds, ...afterNodeIds]);
21
+ const edges = new Map([...before.edges, ...after.edges].map((edge) => [edge.id, edge]));
22
+ const accesses = new Map([...before.stateAccesses, ...after.stateAccesses].map((access) => [access.id, access]));
23
+ const links = new Map([...before.stateLinks, ...after.stateLinks].map((link) => [link.id, link]));
24
+ const unresolved = new Map([...before.unresolved, ...after.unresolved].map((item) => [item.id, item]));
25
+ const association = (change) => {
26
+ const { kind, subjectId } = change;
27
+ if (kind.startsWith('node_') || kind === 'caller_removed' || kind.endsWith('_disconnected'))
28
+ return selectedNodes.has(subjectId);
29
+ if (kind.startsWith('edge_')) {
30
+ const edge = edges.get(subjectId);
31
+ return edge === undefined
32
+ ? undefined
33
+ : selectedNodes.has(edge.from) || selectedNodes.has(edge.to);
34
+ }
35
+ if (kind.startsWith('state_access_')) {
36
+ const access = accesses.get(subjectId);
37
+ return access === undefined ? undefined : selectedNodes.has(access.ownerId);
38
+ }
39
+ if (kind.startsWith('state_link_')) {
40
+ const link = links.get(subjectId);
41
+ if (link === undefined)
42
+ return undefined;
43
+ const producer = accesses.get(link.producerId);
44
+ const consumer = accesses.get(link.consumerId);
45
+ return producer === undefined && consumer === undefined
46
+ ? undefined
47
+ : (producer !== undefined && selectedNodes.has(producer.ownerId)) ||
48
+ (consumer !== undefined && selectedNodes.has(consumer.ownerId));
49
+ }
50
+ if (kind.startsWith('unresolved_')) {
51
+ const item = unresolved.get(subjectId);
52
+ return item?.ownerId === undefined ? undefined : selectedNodes.has(item.ownerId);
53
+ }
54
+ return undefined;
55
+ };
56
+ const selectedChangeIds = [];
57
+ const unattributedChangeIds = [];
58
+ for (const [index, change] of comparison.changes.entries()) {
59
+ if ((index & 255) === 0)
60
+ signal?.throwIfAborted();
61
+ const relevant = association(change);
62
+ if (relevant === true)
63
+ selectedChangeIds.push(change.id);
64
+ else if (relevant === undefined)
65
+ unattributedChangeIds.push(change.id);
66
+ }
67
+ return {
68
+ selector,
69
+ maxChainNodes,
70
+ beforeNodeIds,
71
+ afterNodeIds,
72
+ selectedChangeIds,
73
+ selectedChangeCount: selectedChangeIds.length,
74
+ omittedChangeCount: comparison.changes.length - selectedChangeIds.length,
75
+ unattributedChangeIds,
76
+ truncated: beforeTrace.truncated || afterTrace.truncated,
77
+ };
78
+ }
@@ -2,6 +2,14 @@ import { compareCodeUnits } from '../core/canonical.js';
2
2
  export function eventHelperInventory(graph) {
3
3
  const helpers = new Set(graph.nodes.filter(({ kind }) => kind === 'helper').map(({ id }) => id));
4
4
  const structural = graph.edges.filter(({ derived }) => !derived);
5
+ const structuralById = new Map(structural.map((edge) => [edge.id, edge]));
6
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
7
+ const optionParents = new Map(graph.edges
8
+ .filter(({ reason }) => reason === 'option_branch')
9
+ .map(({ from, to }) => [to, from]));
10
+ const accessById = new Map(graph.stateAccesses
11
+ .filter(({ ownerId }) => helpers.has(ownerId))
12
+ .map((access) => [`state-access:${access.id}`, access]));
5
13
  return {
6
14
  domain: 'event',
7
15
  sourceRevision: graph.revision,
@@ -29,5 +37,45 @@ export function eventHelperInventory(graph) {
29
37
  .map(({ id }) => id)
30
38
  .sort(compareCodeUnits),
31
39
  branches: [...helpers].sort(compareCodeUnits),
40
+ recordDetails: (path) => {
41
+ let current = 'unknown';
42
+ const root = structuralById.get(path[0]?.id ?? '');
43
+ if (root !== undefined) {
44
+ const caller = nodesById.get(optionParents.get(root.from) ?? root.from);
45
+ const candidate = root.scope?.source ?? caller?.metadata.expectedScope ?? caller?.metadata.scope;
46
+ if (candidate === 'country' ||
47
+ candidate === 'state' ||
48
+ candidate === 'province' ||
49
+ candidate === 'unit_leader' ||
50
+ candidate === 'operative' ||
51
+ candidate === 'character' ||
52
+ candidate === 'global')
53
+ current = candidate;
54
+ }
55
+ const transitions = [];
56
+ for (const { id } of path) {
57
+ const edge = structuralById.get(id);
58
+ if (edge?.scope !== undefined) {
59
+ const { source, destination, confidence } = edge.scope;
60
+ current =
61
+ current !== 'unknown' && source !== 'unknown' && current !== source
62
+ ? 'unknown'
63
+ : destination;
64
+ transitions.push({ edgeId: id, source, destination: current, confidence });
65
+ }
66
+ const access = accessById.get(id);
67
+ if (access !== undefined) {
68
+ return {
69
+ scopeEvidence: {
70
+ stateAccessId: access.id,
71
+ declaredScope: access.scope,
72
+ resolvedScope: access.scope === 'unknown' ? current : access.scope,
73
+ transitions,
74
+ },
75
+ };
76
+ }
77
+ }
78
+ return { scopeEvidence: { resolvedScope: current, transitions } };
79
+ },
32
80
  };
33
81
  }
@@ -37,21 +37,27 @@ export function eventGraphCounts(graph, artifacts) {
37
37
  }
38
38
  export function eventGraphValidation(graph) {
39
39
  const counts = eventGraphCounts(graph, 0);
40
+ const analysisBoundaryPassed = graph.complete || (graph.analysisMode === 'focused' && graph.skippedSourceCount === 0);
41
+ const passed = analysisBoundaryPassed && counts.blockingDiagnostics === 0;
40
42
  return {
41
- passed: graph.complete && counts.blockingDiagnostics === 0,
43
+ passed,
42
44
  checks: [
43
45
  {
44
46
  id: 'event-analysis',
45
- passed: graph.complete && counts.blockingDiagnostics === 0,
46
- message: graph.analysisMode === 'focused'
47
- ? 'Large workspace analysis deferred workspace-wide helper projections and lifecycle passes; direct evidence is linked'
47
+ passed,
48
+ message: counts.blockingDiagnostics > 0
49
+ ? `${counts.blockingDiagnostics} blocking event-chain diagnostics; full evidence is linked`
48
50
  : graph.complete
49
- ? `${counts.blockingDiagnostics} blocking event-chain diagnostics; full evidence is linked`
50
- : graph.issues.some(({ code }) => code === 'EVENT_HELPER_DEPTH_LIMIT' ||
51
- code === 'EVENT_HELPER_PROJECTION_LIMIT' ||
52
- code === 'EVENT_HELPER_STATE_PROJECTION_LIMIT')
53
- ? 'Helper expansion reached a depth or materialization boundary; helper_expansion mode provides bounded source-linked continuation'
54
- : `${graph.skippedSourceCount} event-analysis source(s) were skipped; full evidence is linked`,
51
+ ? 'The event-chain analysis is complete and has no blocking diagnostics'
52
+ : graph.analysisMode === 'focused' && graph.skippedSourceCount === 0
53
+ ? 'Whole-workspace helper projections and lifecycle passes were deferred; the requested event analysis retains direct source evidence'
54
+ : graph.analysisMode === 'focused'
55
+ ? `${graph.skippedSourceCount} event-analysis source(s) were skipped; linked evidence records the partial boundary`
56
+ : graph.issues.some(({ code }) => code === 'EVENT_HELPER_DEPTH_LIMIT' ||
57
+ code === 'EVENT_HELPER_PROJECTION_LIMIT' ||
58
+ code === 'EVENT_HELPER_STATE_PROJECTION_LIMIT')
59
+ ? 'Helper expansion reached a depth or materialization boundary; helper_expansion mode provides bounded source-linked continuation'
60
+ : `${graph.skippedSourceCount} event-analysis source(s) were skipped; full evidence is linked`,
55
61
  },
56
62
  ],
57
63
  };
@@ -130,6 +136,15 @@ export async function compareEvents(viewer, input) {
130
136
  ...(compared.render === undefined ? {} : { renderHashes: compared.render.hashes }),
131
137
  counts: {
132
138
  changes: comparison.changes.length,
139
+ ...(compared.selection === undefined
140
+ ? {}
141
+ : {
142
+ selectedChanges: compared.selection.selectedChangeCount,
143
+ omittedChanges: compared.selection.omittedChangeCount,
144
+ unattributedChanges: compared.selection.unattributedChangeIds.length,
145
+ selectedBeforeNodes: compared.selection.beforeNodeIds.length,
146
+ selectedAfterNodes: compared.selection.afterNodeIds.length,
147
+ }),
133
148
  addedNodes: comparison.addedNodeIds.length,
134
149
  removedNodes: comparison.removedNodeIds.length,
135
150
  changedNodes: comparison.changedNodeIds.length,
@@ -155,6 +170,13 @@ export async function compareEvents(viewer, input) {
155
170
  },
156
171
  boundary: {
157
172
  proposedSources: input.proposedSources?.length ?? 0,
173
+ ...(compared.selection === undefined
174
+ ? {}
175
+ : {
176
+ selector: compared.selection.selector,
177
+ maxChainNodes: compared.selection.maxChainNodes,
178
+ selectionTruncated: compared.selection.truncated,
179
+ }),
158
180
  render: input.render ?? true,
159
181
  maxRenderNodes: input.maxRenderNodes ?? 120,
160
182
  refresh: compared.refresh,
@@ -13,6 +13,7 @@ import { buildEventGraph, buildEventGraphAsync } from './graph.js';
13
13
  import { EVENT_FRAGMENT_CACHE_MAX_ENTRIES, EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES, EVENT_PROPOSED_SOURCE_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_FILES, } from './limits.js';
14
14
  import { analyzeEventImpact, discoverEventRoots, explainEventPath, inspectEventStateFlow, lintEventGraph, traceSelectedEvents, } from './queries.js';
15
15
  import { compareEventGraphs, eventGraphHash } from './compare.js';
16
+ import { selectEventComparison } from './comparison-selection.js';
16
17
  import { eventFlowEdges } from './algorithms.js';
17
18
  import { validateEventGraphArtifact } from './artifact-validation.js';
18
19
  import { renderEventGraph, } from './render.js';
@@ -1331,16 +1332,29 @@ export class EventChainViewer {
1331
1332
  after = current;
1332
1333
  }
1333
1334
  const comparison = compareEventGraphs(before, after, input.signal);
1335
+ const selection = input.selector === undefined
1336
+ ? undefined
1337
+ : selectEventComparison(before, after, comparison, input.selector, input.maxChainNodes ?? 1_000, input.signal);
1338
+ if (selection?.beforeNodeIds.length === 0 && selection.afterNodeIds.length === 0)
1339
+ throw new ServiceError('EVENT_COMPARISON_SELECTOR_NOT_FOUND', 'The selector matched no event-chain nodes in either revision');
1334
1340
  graphHashCache.set(before, comparison.beforeGraphHash);
1335
1341
  graphHashCache.set(after, comparison.afterGraphHash);
1336
1342
  let render;
1337
1343
  if (input.render !== false) {
1338
1344
  const projection = comparisonRenderProjection(before, after, comparison, input.signal);
1345
+ const selectedChainIds = selection === undefined
1346
+ ? undefined
1347
+ : new Set([...selection.beforeNodeIds, ...selection.afterNodeIds]);
1348
+ const changedNodeIds = selectedChainIds === undefined
1349
+ ? projection.selectedNodeIds
1350
+ : projection.selectedNodeIds.filter((id) => selectedChainIds.has(id));
1339
1351
  const selector = {
1340
1352
  kind: 'manifest',
1341
1353
  manifest: {
1342
1354
  id: 'comparison',
1343
- nodeIds: projection.selectedNodeIds,
1355
+ nodeIds: changedNodeIds.length > 0
1356
+ ? changedNodeIds
1357
+ : (selection?.afterNodeIds.slice(0, 1) ?? selection?.beforeNodeIds.slice(0, 1) ?? []),
1344
1358
  },
1345
1359
  };
1346
1360
  render = await renderEventGraph(projection.graph, {
@@ -1354,7 +1368,7 @@ export class EventChainViewer {
1354
1368
  });
1355
1369
  }
1356
1370
  const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
1357
- const prefix = safeSlug(`event-compare-${before.revision.slice(0, 8)}-${after.revision.slice(0, 8)}`);
1371
+ const prefix = safeSlug(`event-compare-${before.revision.slice(0, 8)}-${after.revision.slice(0, 8)}${selection === undefined ? '' : `-${hashCanonical({ selector: selection.selector, maxChainNodes: selection.maxChainNodes }).slice(0, 8)}`}`);
1358
1372
  const resourceNames = {
1359
1373
  json: `${prefix}.json`,
1360
1374
  ...(render === undefined
@@ -1380,6 +1394,9 @@ export class EventChainViewer {
1380
1394
  filters: {
1381
1395
  ...(input.before === undefined ? {} : { before: input.before }),
1382
1396
  ...(input.after === undefined ? {} : { after: input.after }),
1397
+ ...(input.selector === undefined
1398
+ ? {}
1399
+ : { selector: input.selector, maxChainNodes: input.maxChainNodes ?? 1_000 }),
1383
1400
  proposedSources: input.proposedSources?.map(({ relativePath, source, expectedSourceHash }) => ({
1384
1401
  relativePath,
1385
1402
  operation: source === null ? 'delete' : 'overlay',
@@ -1397,6 +1414,7 @@ export class EventChainViewer {
1397
1414
  evidence: comparisonEntityEvidence(before, after, comparison, input.signal),
1398
1415
  resources: resourceNames,
1399
1416
  comparison,
1417
+ ...(selection === undefined ? {} : { selection }),
1400
1418
  })}\n`;
1401
1419
  const provenance = eventProvenance(after, 'event-comparison', {
1402
1420
  beforeRevision: before.revision,
@@ -1447,6 +1465,7 @@ export class EventChainViewer {
1447
1465
  after,
1448
1466
  refresh,
1449
1467
  comparison,
1468
+ ...(selection === undefined ? {} : { selection }),
1450
1469
  comparisonJson,
1451
1470
  ...(render === undefined ? {} : { render }),
1452
1471
  artifacts,