hoi4-agent-tools 3.1.0 → 3.3.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 +16 -0
- package/README.md +10 -2
- package/dist/hoi4_agent_tools/core/analysis-evidence.js +9 -0
- package/dist/hoi4_agent_tools/core/analysis-job-operations.js +48 -0
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +21 -1
- package/dist/hoi4_agent_tools/core/impact-comparison.js +49 -0
- package/dist/hoi4_agent_tools/core/impact-graph.js +301 -0
- package/dist/hoi4_agent_tools/core/impact-semantic.js +256 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +5 -0
- package/dist/hoi4_agent_tools/core/job-worker.js +2 -0
- package/dist/hoi4_agent_tools/core/operation-tasks.js +12 -0
- package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
- package/dist/hoi4_agent_tools/core/source-overlay.js +125 -0
- package/dist/hoi4_agent_tools/decision/ai.js +104 -0
- package/dist/hoi4_agent_tools/decision/comparison.js +106 -0
- package/dist/hoi4_agent_tools/decision/cost.js +177 -0
- package/dist/hoi4_agent_tools/decision/evaluation.js +256 -0
- package/dist/hoi4_agent_tools/decision/lifecycle.js +114 -0
- package/dist/hoi4_agent_tools/decision/service.js +172 -0
- package/dist/hoi4_agent_tools/decision/source-inventory.js +182 -0
- package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
- package/dist/hoi4_agent_tools/impact/scenario-suites.js +74 -0
- package/dist/hoi4_agent_tools/impact/service.js +127 -0
- package/dist/hoi4_agent_tools/mcp/server/create.js +2 -0
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +5 -0
- package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +2 -0
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +155 -0
- package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
- package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
- package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
- package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
- package/dist/hoi4_agent_tools/package-check/service.js +231 -0
- package/dist/hoi4_agent_tools/probability/evaluation.js +3 -1
- package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
- package/dist/hoi4_agent_tools/schemas/analysis.js +228 -0
- package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +2 -0
- package/docs/analysis.md +100 -0
- package/docs/jobs.md +2 -1
- package/docs/mechanics.md +70 -0
- package/package.json +6 -2
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +290 -0
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +203 -0
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +650 -0
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +164 -0
- package/schemas/package-manifest.schema.json +142 -0
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1004 -0
- package/schemas/scenario-test-input.schema.json +1057 -0
- package/schemas/shared-scenario.schema.json +237 -0
- package/server.json +3 -3
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { analysisSourceEvidence } from '../core/analysis-evidence.js';
|
|
2
|
+
import { publicArtifactLink } from '../core/artifacts.js';
|
|
3
|
+
import { canonicalJson, hashCanonical } from '../core/canonical.js';
|
|
4
|
+
import { setInlineFilesScanned } from '../core/operation-result.js';
|
|
5
|
+
import { emptyServiceResult, ServiceError } from '../core/result.js';
|
|
6
|
+
import { buildProposedSourceOverlay } from '../core/source-overlay.js';
|
|
7
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
8
|
+
import { inspectDecisionAi } from './ai.js';
|
|
9
|
+
import { compareDecisionScenarios, inspectDecisionScenario } from './comparison.js';
|
|
10
|
+
import { decisionSourceInventory, decisionTargetCatalog } from './source-inventory.js';
|
|
11
|
+
/** Authorized, read-only decision operation over the shared source scanner. */
|
|
12
|
+
export class DecisionAnalyzer {
|
|
13
|
+
engine;
|
|
14
|
+
constructor(engine) {
|
|
15
|
+
this.engine = engine;
|
|
16
|
+
}
|
|
17
|
+
async inspect(input) {
|
|
18
|
+
const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
|
|
19
|
+
if (input.refresh)
|
|
20
|
+
this.engine.invalidate(input.workspaceId);
|
|
21
|
+
const before = await this.engine.scan(input.workspaceId, {}, input.principal, input.signal);
|
|
22
|
+
if (input.expectedRevision !== undefined && before.revision !== input.expectedRevision)
|
|
23
|
+
throw new ServiceError('DECISION_SOURCE_STALE', 'Decision source revision changed since selection', {
|
|
24
|
+
expectedRevision: input.expectedRevision,
|
|
25
|
+
observedRevision: before.revision,
|
|
26
|
+
});
|
|
27
|
+
if (input.mode !== 'inventory' && input.id === undefined)
|
|
28
|
+
throw new ServiceError('DECISION_ID_REQUIRED', 'Decision analysis requires an identifier');
|
|
29
|
+
if (input.mode !== 'inventory' && input.scenarios.length === 0)
|
|
30
|
+
throw new ServiceError('DECISION_SCENARIOS_REQUIRED', 'Decision analysis requires scenarios');
|
|
31
|
+
if (input.mode === 'compare' && input.proposedSources === undefined)
|
|
32
|
+
throw new ServiceError('DECISION_PROPOSAL_REQUIRED', 'Decision comparison requires proposed sources');
|
|
33
|
+
if (input.mode !== 'compare' && input.proposedSources !== undefined)
|
|
34
|
+
throw new ServiceError('DECISION_PROPOSAL_MODE', 'Proposed sources require compare mode');
|
|
35
|
+
const inventory = decisionSourceInventory(before);
|
|
36
|
+
const overlay = input.proposedSources === undefined
|
|
37
|
+
? undefined
|
|
38
|
+
: buildProposedSourceOverlay(before, workspace, input.proposedSources);
|
|
39
|
+
const scenarios = input.scenarios.map((scenario) => ({
|
|
40
|
+
id: scenario.id,
|
|
41
|
+
state: scenario.state,
|
|
42
|
+
...(scenario.actor === undefined ? {} : { actor: scenario.actor }),
|
|
43
|
+
...(scenario.date === undefined ? {} : { date: scenario.date }),
|
|
44
|
+
...(scenario.flags === undefined ? {} : { flags: scenario.flags }),
|
|
45
|
+
...(scenario.eventTargets === undefined ? {} : { eventTargets: scenario.eventTargets }),
|
|
46
|
+
...(scenario.candidateOverrides === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { candidateOverrides: scenario.candidateOverrides }),
|
|
49
|
+
...(scenario.closedFlags === undefined ? {} : { closedFlags: scenario.closedFlags }),
|
|
50
|
+
...(scenario.scopes === undefined
|
|
51
|
+
? {}
|
|
52
|
+
: {
|
|
53
|
+
scopes: Object.fromEntries(Object.entries(scenario.scopes).map(([key, binding]) => [
|
|
54
|
+
key,
|
|
55
|
+
{
|
|
56
|
+
id: binding.id,
|
|
57
|
+
state: binding.state,
|
|
58
|
+
...(binding.type === undefined ? {} : { type: binding.type }),
|
|
59
|
+
...(binding.actor === undefined ? {} : { actor: binding.actor }),
|
|
60
|
+
...(binding.flags === undefined ? {} : { flags: binding.flags }),
|
|
61
|
+
...(binding.eventTargets === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { eventTargets: binding.eventTargets }),
|
|
64
|
+
...(binding.weight === undefined ? {} : { weight: binding.weight }),
|
|
65
|
+
},
|
|
66
|
+
])),
|
|
67
|
+
}),
|
|
68
|
+
}));
|
|
69
|
+
const analysis = input.mode === 'inventory'
|
|
70
|
+
? {
|
|
71
|
+
decisions: inventory.decisions.map((source) => ({
|
|
72
|
+
id: source.id,
|
|
73
|
+
category: source.category,
|
|
74
|
+
kind: source.kind,
|
|
75
|
+
path: source.path,
|
|
76
|
+
rootKind: source.rootKind,
|
|
77
|
+
loadOrder: source.loadOrder,
|
|
78
|
+
...(source.location === undefined ? {} : { location: source.location }),
|
|
79
|
+
targeting: decisionTargetCatalog(source),
|
|
80
|
+
fields: Object.fromEntries(Object.entries(source.fields)
|
|
81
|
+
.filter(([, occurrences]) => occurrences.length > 0)
|
|
82
|
+
.map(([field, occurrences]) => [field, occurrences.length])),
|
|
83
|
+
})),
|
|
84
|
+
categories: inventory.categories.map(({ id, path, rootKind, loadOrder, location }) => ({
|
|
85
|
+
id,
|
|
86
|
+
path,
|
|
87
|
+
rootKind,
|
|
88
|
+
loadOrder,
|
|
89
|
+
...(location === undefined ? {} : { location }),
|
|
90
|
+
})),
|
|
91
|
+
overrides: inventory.overrides,
|
|
92
|
+
unresolvedDefinitions: inventory.unresolvedDefinitions,
|
|
93
|
+
}
|
|
94
|
+
: input.mode === 'compare' && overlay !== undefined
|
|
95
|
+
? compareDecisionScenarios(before, overlay.snapshot, input.id, scenarios)
|
|
96
|
+
: scenarios.map((scenario) => ({
|
|
97
|
+
scenarioId: scenario.id,
|
|
98
|
+
inspection: inspectDecisionScenario(before, input.id, scenario),
|
|
99
|
+
}));
|
|
100
|
+
const ai = input.mode === 'inventory'
|
|
101
|
+
? undefined
|
|
102
|
+
: await inspectDecisionAi(this.engine, before, input.id, scenarios, overlay, input.principal, input.signal);
|
|
103
|
+
const sourceComplete = input.mode === 'inventory'
|
|
104
|
+
? inventory.complete
|
|
105
|
+
: input.mode === 'compare'
|
|
106
|
+
? 'complete' in analysis && analysis.complete
|
|
107
|
+
: inventory.complete &&
|
|
108
|
+
Array.isArray(analysis) &&
|
|
109
|
+
analysis.every(({ inspection }) => inspection !== null &&
|
|
110
|
+
inspection.gates.complete &&
|
|
111
|
+
inspection.cost.unresolved.length === 0 &&
|
|
112
|
+
inspection.lifecycle.unresolved.length === 0);
|
|
113
|
+
const complete = sourceComplete &&
|
|
114
|
+
(ai === undefined ||
|
|
115
|
+
('status' in ai &&
|
|
116
|
+
ai.status === 'complete' &&
|
|
117
|
+
!('proposedUnresolved' in ai) &&
|
|
118
|
+
!('baselineUnresolved' in ai)));
|
|
119
|
+
const report = {
|
|
120
|
+
schemaVersion: 'decision-analysis.v1',
|
|
121
|
+
mode: input.mode,
|
|
122
|
+
sourceRevision: before.revision,
|
|
123
|
+
...(overlay === undefined ? {} : { proposedRevision: overlay.snapshot.revision }),
|
|
124
|
+
...(input.id === undefined ? {} : { id: input.id }),
|
|
125
|
+
...(overlay === undefined ? {} : { proposedChanges: overlay.changes }),
|
|
126
|
+
analysis,
|
|
127
|
+
...(ai === undefined ? {} : { ai }),
|
|
128
|
+
};
|
|
129
|
+
const reportHash = hashCanonical(report);
|
|
130
|
+
const provenanceSources = analysisSourceEvidence(before, overlay?.snapshot);
|
|
131
|
+
const artifact = await this.engine.artifacts.putChunked(workspace, `decision-${reportHash.slice(0, 24)}.json`, 'application/json', `${canonicalJson(report)}\n`, {
|
|
132
|
+
kind: 'decision-analysis',
|
|
133
|
+
toolVersion: PACKAGE_VERSION,
|
|
134
|
+
schemaVersion: 'decision-analysis.v1',
|
|
135
|
+
sourceHashes: provenanceSources.sourceHashes,
|
|
136
|
+
metadata: {
|
|
137
|
+
beforeRevision: before.revision,
|
|
138
|
+
...(overlay === undefined ? {} : { afterRevision: overlay.snapshot.revision }),
|
|
139
|
+
sourceInventory: provenanceSources.inventory,
|
|
140
|
+
},
|
|
141
|
+
}, 'Revision-pinned decision inventory or declared-scenario evidence', input.signal);
|
|
142
|
+
const result = emptyServiceResult(input.workspaceId, {
|
|
143
|
+
mode: input.mode,
|
|
144
|
+
sourceRevision: before.revision,
|
|
145
|
+
...(overlay === undefined ? {} : { proposedRevision: overlay.snapshot.revision }),
|
|
146
|
+
reportHash,
|
|
147
|
+
complete,
|
|
148
|
+
decisions: inventory.decisions.length,
|
|
149
|
+
categories: inventory.categories.length,
|
|
150
|
+
overrides: inventory.overrides.length,
|
|
151
|
+
scenarios: input.mode === 'inventory' ? 0 : input.scenarios.length,
|
|
152
|
+
unresolvedDefinitions: inventory.unresolvedDefinitions.length,
|
|
153
|
+
});
|
|
154
|
+
result.code = complete ? 'DECISION_ANALYZED' : 'DECISION_ANALYZED_PARTIAL';
|
|
155
|
+
result.artifacts = [publicArtifactLink(artifact)];
|
|
156
|
+
result.proposedFiles = overlay?.changes.map(({ path }) => path).slice(0, 64) ?? [];
|
|
157
|
+
setInlineFilesScanned(result, before.files.map(({ displayPath }) => displayPath));
|
|
158
|
+
result.validation = {
|
|
159
|
+
passed: complete,
|
|
160
|
+
checks: [
|
|
161
|
+
{
|
|
162
|
+
id: 'decision-coverage',
|
|
163
|
+
passed: complete,
|
|
164
|
+
message: complete
|
|
165
|
+
? 'Decision evidence is complete under the declared scenarios'
|
|
166
|
+
: 'Decision evidence contains a missing definition, unbound scenario input, or unsupported source path; inspect the linked report',
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { compareCodeUnits } from '../core/canonical.js';
|
|
2
|
+
import { assignments, parseClausewitz, } from '../core/source/index.js';
|
|
3
|
+
export const decisionFieldNames = [
|
|
4
|
+
'allowed',
|
|
5
|
+
'visible',
|
|
6
|
+
'available',
|
|
7
|
+
'targets',
|
|
8
|
+
'target_array',
|
|
9
|
+
'state_trigger',
|
|
10
|
+
'target_root_trigger',
|
|
11
|
+
'target_trigger',
|
|
12
|
+
'cost',
|
|
13
|
+
'custom_cost_trigger',
|
|
14
|
+
'custom_cost_text',
|
|
15
|
+
'complete_effect',
|
|
16
|
+
'fire_only_once',
|
|
17
|
+
'days_re_enable',
|
|
18
|
+
'days_remove',
|
|
19
|
+
'remove_trigger',
|
|
20
|
+
'remove_effect',
|
|
21
|
+
'cancel_trigger',
|
|
22
|
+
'cancel_if_not_visible',
|
|
23
|
+
'cancel_effect',
|
|
24
|
+
'days_mission_timeout',
|
|
25
|
+
'timeout_effect',
|
|
26
|
+
'activation',
|
|
27
|
+
'selectable_mission',
|
|
28
|
+
'ai_will_do',
|
|
29
|
+
];
|
|
30
|
+
/** Static targeting declarations; scenario evaluation determines whether a target qualifies. */
|
|
31
|
+
export function decisionTargetCatalog(source) {
|
|
32
|
+
const explicitTargets = source.fields.targets.flatMap((assignment) => {
|
|
33
|
+
if (assignment.value.type !== 'block')
|
|
34
|
+
return [];
|
|
35
|
+
return [
|
|
36
|
+
...assignment.value.entries
|
|
37
|
+
.filter((entry) => entry.type === 'scalar')
|
|
38
|
+
.map((entry) => entry.value),
|
|
39
|
+
...assignments(assignment.value, 'state')
|
|
40
|
+
.filter((entry) => entry.value.type === 'scalar')
|
|
41
|
+
.map((entry) => entry.value.value),
|
|
42
|
+
];
|
|
43
|
+
});
|
|
44
|
+
const targetArrays = source.fields.target_array.flatMap((assignment) => assignment.value.type === 'scalar' ? [assignment.value.value] : []);
|
|
45
|
+
const stateFilters = source.fields.state_trigger.flatMap((assignment) => assignment.value.type === 'scalar' ? [assignment.value.value] : []);
|
|
46
|
+
const hasTargetRootTrigger = source.fields.target_root_trigger.length > 0;
|
|
47
|
+
const hasTargetTrigger = source.fields.target_trigger.length > 0;
|
|
48
|
+
return {
|
|
49
|
+
targeted: explicitTargets.length > 0 ||
|
|
50
|
+
targetArrays.length > 0 ||
|
|
51
|
+
stateFilters.some((filter) => filter !== 'no') ||
|
|
52
|
+
hasTargetRootTrigger ||
|
|
53
|
+
hasTargetTrigger,
|
|
54
|
+
explicitTargets,
|
|
55
|
+
targetArrays,
|
|
56
|
+
stateFilters,
|
|
57
|
+
hasTargetRootTrigger,
|
|
58
|
+
hasTargetTrigger,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function fields(block) {
|
|
62
|
+
return Object.fromEntries(decisionFieldNames.map((name) => [name, assignments(block, name)]));
|
|
63
|
+
}
|
|
64
|
+
function sourceDocument(file, documents) {
|
|
65
|
+
if (file === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
const existing = documents.get(file.displayPath);
|
|
68
|
+
if (existing !== undefined)
|
|
69
|
+
return existing;
|
|
70
|
+
const parsed = parseClausewitz(file.bytes, file.displayPath);
|
|
71
|
+
documents.set(file.displayPath, parsed);
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
function categoryBlock(document, id) {
|
|
75
|
+
return assignments(document.root, id).find((assignment) => assignment.value.type === 'block')
|
|
76
|
+
?.value;
|
|
77
|
+
}
|
|
78
|
+
function sourceOrder(left, right) {
|
|
79
|
+
return (compareCodeUnits(left.id, right.id) ||
|
|
80
|
+
right.loadOrder - left.loadOrder ||
|
|
81
|
+
compareCodeUnits(left.path, right.path));
|
|
82
|
+
}
|
|
83
|
+
/** Reuses the shared index's active/overridden selection and source inventory. */
|
|
84
|
+
export function decisionSourceInventory(snapshot) {
|
|
85
|
+
const files = new Map(snapshot.files.map((file) => [file.displayPath, file]));
|
|
86
|
+
const documents = new Map();
|
|
87
|
+
const unresolvedDefinitions = [];
|
|
88
|
+
const categories = [];
|
|
89
|
+
const decisions = [];
|
|
90
|
+
const overrides = [
|
|
91
|
+
...snapshot.index.findAll('decision_category'),
|
|
92
|
+
...snapshot.index.findAll('decision'),
|
|
93
|
+
]
|
|
94
|
+
.filter(({ overridden }) => overridden)
|
|
95
|
+
.sort(sourceOrder)
|
|
96
|
+
.map(({ kind, id, path, rootKind, loadOrder, location, sourceShadowed }) => ({
|
|
97
|
+
kind,
|
|
98
|
+
id,
|
|
99
|
+
path,
|
|
100
|
+
rootKind,
|
|
101
|
+
loadOrder,
|
|
102
|
+
...(location === undefined ? {} : { location }),
|
|
103
|
+
sourceShadowed,
|
|
104
|
+
}));
|
|
105
|
+
for (const symbol of snapshot.index
|
|
106
|
+
.findAll('decision_category')
|
|
107
|
+
.filter(({ overridden }) => !overridden)
|
|
108
|
+
.sort(sourceOrder)) {
|
|
109
|
+
const file = files.get(symbol.path);
|
|
110
|
+
const document = sourceDocument(file, documents);
|
|
111
|
+
const block = document === undefined ? undefined : categoryBlock(document, symbol.id);
|
|
112
|
+
if (file === undefined || document === undefined || block === undefined) {
|
|
113
|
+
unresolvedDefinitions.push({
|
|
114
|
+
kind: 'decision_category',
|
|
115
|
+
id: symbol.id,
|
|
116
|
+
path: symbol.path,
|
|
117
|
+
reason: 'Active category index entry could not be matched to a source block',
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
categories.push({
|
|
122
|
+
id: symbol.id,
|
|
123
|
+
path: symbol.path,
|
|
124
|
+
rootKind: file.rootKind,
|
|
125
|
+
loadOrder: file.loadOrder,
|
|
126
|
+
sourceHash: file.sha256,
|
|
127
|
+
...(symbol.location === undefined ? {} : { location: symbol.location }),
|
|
128
|
+
document,
|
|
129
|
+
block,
|
|
130
|
+
fields: fields(block),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
for (const symbol of snapshot.index
|
|
134
|
+
.findAll('decision')
|
|
135
|
+
.filter(({ overridden }) => !overridden)
|
|
136
|
+
.sort(sourceOrder)) {
|
|
137
|
+
const category = typeof symbol.metadata.category === 'string' ? symbol.metadata.category : undefined;
|
|
138
|
+
const file = files.get(symbol.path);
|
|
139
|
+
const document = sourceDocument(file, documents);
|
|
140
|
+
const categoryNode = document === undefined || category === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: categoryBlock(document, category);
|
|
143
|
+
const decisionNode = categoryNode === undefined
|
|
144
|
+
? undefined
|
|
145
|
+
: assignments(categoryNode, symbol.id).find((assignment) => assignment.value.type === 'block')?.value;
|
|
146
|
+
if (file === undefined ||
|
|
147
|
+
document === undefined ||
|
|
148
|
+
category === undefined ||
|
|
149
|
+
decisionNode?.type !== 'block') {
|
|
150
|
+
unresolvedDefinitions.push({
|
|
151
|
+
kind: 'decision',
|
|
152
|
+
id: symbol.id,
|
|
153
|
+
path: symbol.path,
|
|
154
|
+
reason: 'Active decision index entry could not be matched to a source block',
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const decisionFields = fields(decisionNode);
|
|
159
|
+
decisions.push({
|
|
160
|
+
id: symbol.id,
|
|
161
|
+
category,
|
|
162
|
+
kind: decisionFields.days_mission_timeout.length > 0 ? 'mission' : 'decision',
|
|
163
|
+
path: symbol.path,
|
|
164
|
+
rootKind: file.rootKind,
|
|
165
|
+
loadOrder: file.loadOrder,
|
|
166
|
+
sourceHash: file.sha256,
|
|
167
|
+
...(symbol.location === undefined ? {} : { location: symbol.location }),
|
|
168
|
+
document,
|
|
169
|
+
block: decisionNode,
|
|
170
|
+
fields: decisionFields,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
sourceRevision: snapshot.revision,
|
|
175
|
+
complete: snapshot.complete && unresolvedDefinitions.length === 0,
|
|
176
|
+
skippedSourceCount: snapshot.skippedSourceCount,
|
|
177
|
+
decisions,
|
|
178
|
+
categories,
|
|
179
|
+
overrides,
|
|
180
|
+
unresolvedDefinitions,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
|
|
2
2
|
import { hasBlockingDiagnostics } from '../core/diagnostics.js';
|
|
3
3
|
import { evaluateTriggerBlock } from '../core/condition-evaluator.js';
|
|
4
|
+
import { scenarioFromGui } from '../core/scenario-model.js';
|
|
4
5
|
import { resolveScopeContext, rootScopeContext } from '../core/scenario-state.js';
|
|
5
6
|
import { assignments, firstScalar, parseClausewitz } from '../core/source/index.js';
|
|
6
7
|
/** Keep declared scoped scalars out of exploratory value generation. */
|
|
@@ -30,29 +31,7 @@ function parsedCondition(expression) {
|
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
export function guiConditionScenario(base, values = base.values) {
|
|
33
|
-
|
|
34
|
-
for (const [key, value] of Object.entries(base.flags))
|
|
35
|
-
state[`flag.${key}`] = value;
|
|
36
|
-
const scopes = structuredClone(base.scopes ?? {});
|
|
37
|
-
for (const [key, value] of Object.entries(state)) {
|
|
38
|
-
const match = /^(ROOT|THIS|FROM(?:\.FROM)*|PREV)\.(.+)$/u.exec(key);
|
|
39
|
-
if (match === null)
|
|
40
|
-
continue;
|
|
41
|
-
if (match[1] === 'ROOT' || match[1] === 'THIS')
|
|
42
|
-
state[match[2]] ??= value;
|
|
43
|
-
else {
|
|
44
|
-
const scope = (scopes[match[1]] ??= { id: match[1], state: {} });
|
|
45
|
-
scope.state[match[2]] ??= value;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
const actor = base.country?.tag ?? base.country?.countryTag ?? base.country?.GetTag;
|
|
49
|
-
return {
|
|
50
|
-
id: base.id,
|
|
51
|
-
state,
|
|
52
|
-
scopes,
|
|
53
|
-
closedFlags: false,
|
|
54
|
-
...(typeof actor === 'string' ? { actor } : {}),
|
|
55
|
-
};
|
|
34
|
+
return scenarioFromGui(base, values);
|
|
56
35
|
}
|
|
57
36
|
export function evaluateGuiCondition(expression, scenario, subject, definitions = new ClausewitzEvaluationDefinitions(), scope = 'ROOT') {
|
|
58
37
|
const context = resolveScopeContext(scenario, scope, rootScopeContext(scenario));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { compareCodeUnits, sha256Bytes } from '../core/canonical.js';
|
|
4
|
+
import { ServiceError } from '../core/result.js';
|
|
5
|
+
import { canonicalPath, isWithin } from '../core/workspace.js';
|
|
6
|
+
import { impactScenarioSuiteReferenceSchema } from '../schemas/analysis.js';
|
|
7
|
+
function selectorKey(selector) {
|
|
8
|
+
return `${selector.kind}:${selector.id}`;
|
|
9
|
+
}
|
|
10
|
+
/** Read bounded workspace-owned suite references without executing any case or assertion. */
|
|
11
|
+
export async function inspectImpactScenarioSuites(workspace, paths, graph, selected, signal) {
|
|
12
|
+
const result = {
|
|
13
|
+
matches: [],
|
|
14
|
+
sourceHashes: {},
|
|
15
|
+
unresolved: [],
|
|
16
|
+
scannedSuites: 0,
|
|
17
|
+
scannedCases: 0,
|
|
18
|
+
omittedMatches: 0,
|
|
19
|
+
complete: true,
|
|
20
|
+
};
|
|
21
|
+
const relevantSymbols = new Set([
|
|
22
|
+
...selected.map(selectorKey),
|
|
23
|
+
...graph.directConsumers.map(({ source }) => selectorKey(source)),
|
|
24
|
+
...graph.transitiveConsumers.map(({ source }) => selectorKey(source)),
|
|
25
|
+
]);
|
|
26
|
+
const relevantFiles = new Set(graph.affectedFiles);
|
|
27
|
+
for (const relativePath of [...new Set(paths)].sort(compareCodeUnits)) {
|
|
28
|
+
signal?.throwIfAborted();
|
|
29
|
+
const candidate = await canonicalPath(path.join(workspace.modRoot, relativePath), signal);
|
|
30
|
+
if (!isWithin(workspace.modRoot, candidate))
|
|
31
|
+
throw new ServiceError('IMPACT_SUITE_OUTSIDE_WORKSPACE', 'Scenario suite resolves outside the authorized mod root', {
|
|
32
|
+
relativePath,
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const details = await stat(candidate);
|
|
36
|
+
if (!details.isFile() || details.size > 4 * 1024 * 1024)
|
|
37
|
+
throw new RangeError('Suite must be a JSON file no larger than 4 MiB');
|
|
38
|
+
const bytes = await readFile(candidate);
|
|
39
|
+
if (bytes.length > 4 * 1024 * 1024)
|
|
40
|
+
throw new RangeError('Suite file exceeds 4 MiB');
|
|
41
|
+
const suite = impactScenarioSuiteReferenceSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
42
|
+
result.sourceHashes[`suite:${relativePath}`] = sha256Bytes(bytes);
|
|
43
|
+
result.scannedSuites += 1;
|
|
44
|
+
result.scannedCases += suite.cases.length;
|
|
45
|
+
for (const item of suite.cases) {
|
|
46
|
+
const via = [
|
|
47
|
+
...item.sourceSelectors
|
|
48
|
+
.map(selectorKey)
|
|
49
|
+
.filter((key) => relevantSymbols.has(key))
|
|
50
|
+
.map((key) => `symbol:${key}`),
|
|
51
|
+
...item.sourceFiles
|
|
52
|
+
.filter((file) => relevantFiles.has(file))
|
|
53
|
+
.map((file) => `file:${file}`),
|
|
54
|
+
].sort(compareCodeUnits);
|
|
55
|
+
if (via.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
if (result.matches.length < 10_000)
|
|
58
|
+
result.matches.push({ suitePath: relativePath, suiteId: suite.id, caseId: item.id, via });
|
|
59
|
+
else
|
|
60
|
+
result.omittedMatches += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (signal?.aborted)
|
|
65
|
+
throw error;
|
|
66
|
+
result.unresolved.push({
|
|
67
|
+
path: relativePath,
|
|
68
|
+
reason: error instanceof Error ? error.message : 'Scenario suite could not be read or parsed',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
result.complete = result.unresolved.length === 0 && result.omittedMatches === 0;
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { analysisSourceEvidence } from '../core/analysis-evidence.js';
|
|
2
|
+
import { publicArtifactLink } from '../core/artifacts.js';
|
|
3
|
+
import { canonicalJson, hashCanonical } from '../core/canonical.js';
|
|
4
|
+
import { compareImpactGraphs } from '../core/impact-comparison.js';
|
|
5
|
+
import { inspectImpactGraph } from '../core/impact-graph.js';
|
|
6
|
+
import { setInlineFilesScanned } from '../core/operation-result.js';
|
|
7
|
+
import { emptyServiceResult, ServiceError } from '../core/result.js';
|
|
8
|
+
import { buildProposedSourceOverlay } from '../core/source-overlay.js';
|
|
9
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
10
|
+
import { inspectImpactScenarioSuites } from './scenario-suites.js';
|
|
11
|
+
/** Authorized, read-only impact operation shared by ordinary calls and persistent jobs. */
|
|
12
|
+
export class ImpactAnalyzer {
|
|
13
|
+
engine;
|
|
14
|
+
constructor(engine) {
|
|
15
|
+
this.engine = engine;
|
|
16
|
+
}
|
|
17
|
+
async inspect(input) {
|
|
18
|
+
const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
|
|
19
|
+
if (input.refresh)
|
|
20
|
+
this.engine.invalidate(input.workspaceId);
|
|
21
|
+
const before = await this.engine.scan(input.workspaceId, {}, input.principal, input.signal);
|
|
22
|
+
if (input.expectedRevision !== undefined && before.revision !== input.expectedRevision)
|
|
23
|
+
throw new ServiceError('IMPACT_SOURCE_STALE', 'Impact source revision changed since selection', {
|
|
24
|
+
expectedRevision: input.expectedRevision,
|
|
25
|
+
observedRevision: before.revision,
|
|
26
|
+
});
|
|
27
|
+
const overlay = input.proposedSources === undefined
|
|
28
|
+
? undefined
|
|
29
|
+
: buildProposedSourceOverlay(before, workspace, input.proposedSources);
|
|
30
|
+
const availablePaths = new Set([
|
|
31
|
+
...before.files.map(({ displayPath }) => displayPath),
|
|
32
|
+
...(overlay?.snapshot.files.map(({ displayPath }) => displayPath) ?? []),
|
|
33
|
+
...(overlay?.changes.map(({ path }) => path) ?? []),
|
|
34
|
+
]);
|
|
35
|
+
for (const file of input.changedFiles)
|
|
36
|
+
if (!availablePaths.has(file))
|
|
37
|
+
throw new ServiceError('IMPACT_SOURCE_UNKNOWN', 'Changed file is not in the authorized source inventory or proposed overlay', { file });
|
|
38
|
+
const query = {
|
|
39
|
+
symbols: input.symbols,
|
|
40
|
+
changedFiles: input.changedFiles.length > 0
|
|
41
|
+
? input.changedFiles
|
|
42
|
+
: (overlay?.changes.map(({ path }) => path) ?? []),
|
|
43
|
+
...(input.maxNodes === undefined ? {} : { maxNodes: input.maxNodes }),
|
|
44
|
+
...(input.maxEdges === undefined ? {} : { maxEdges: input.maxEdges }),
|
|
45
|
+
...(input.maxDepth === undefined ? {} : { maxDepth: input.maxDepth }),
|
|
46
|
+
};
|
|
47
|
+
const comparison = overlay === undefined ? undefined : compareImpactGraphs(before, overlay.snapshot, query);
|
|
48
|
+
const graph = comparison?.after ?? inspectImpactGraph(before, query);
|
|
49
|
+
const analysis = comparison ?? graph;
|
|
50
|
+
const suiteGraph = comparison === undefined
|
|
51
|
+
? graph
|
|
52
|
+
: {
|
|
53
|
+
...graph,
|
|
54
|
+
directConsumers: [...comparison.before.directConsumers, ...graph.directConsumers],
|
|
55
|
+
transitiveConsumers: [
|
|
56
|
+
...comparison.before.transitiveConsumers,
|
|
57
|
+
...graph.transitiveConsumers,
|
|
58
|
+
],
|
|
59
|
+
affectedFiles: [
|
|
60
|
+
...new Set([...comparison.before.affectedFiles, ...graph.affectedFiles]),
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
const scenarioSuites = await inspectImpactScenarioSuites(workspace, input.scenarioSuites, suiteGraph, input.symbols, input.signal);
|
|
64
|
+
const complete = analysis.complete && scenarioSuites.complete;
|
|
65
|
+
const report = {
|
|
66
|
+
schemaVersion: 'impact-analysis.v1',
|
|
67
|
+
mode: overlay === undefined ? 'inspect' : 'compare',
|
|
68
|
+
query,
|
|
69
|
+
...(overlay === undefined ? {} : { proposedChanges: overlay.changes }),
|
|
70
|
+
analysis,
|
|
71
|
+
scenarioSuites,
|
|
72
|
+
};
|
|
73
|
+
const reportHash = hashCanonical(report);
|
|
74
|
+
const artifactName = `impact-${reportHash.slice(0, 24)}.json`;
|
|
75
|
+
const provenanceSources = analysisSourceEvidence(before, overlay?.snapshot, scenarioSuites.sourceHashes);
|
|
76
|
+
const artifact = await this.engine.artifacts.putChunked(workspace, artifactName, 'application/json', `${canonicalJson(report)}\n`, {
|
|
77
|
+
kind: 'impact-analysis',
|
|
78
|
+
toolVersion: PACKAGE_VERSION,
|
|
79
|
+
schemaVersion: 'impact-analysis.v1',
|
|
80
|
+
sourceHashes: provenanceSources.sourceHashes,
|
|
81
|
+
metadata: {
|
|
82
|
+
beforeRevision: before.revision,
|
|
83
|
+
...(overlay === undefined ? {} : { afterRevision: overlay.snapshot.revision }),
|
|
84
|
+
sourceInventory: provenanceSources.inventory,
|
|
85
|
+
},
|
|
86
|
+
}, 'Revision-pinned cross-system impact graph and source evidence', input.signal);
|
|
87
|
+
const result = emptyServiceResult(input.workspaceId, {
|
|
88
|
+
mode: overlay === undefined ? 'inspect' : 'compare',
|
|
89
|
+
sourceRevision: before.revision,
|
|
90
|
+
...(overlay === undefined ? {} : { proposedRevision: overlay.snapshot.revision }),
|
|
91
|
+
reportHash,
|
|
92
|
+
complete,
|
|
93
|
+
definitions: graph.definitions.length,
|
|
94
|
+
directConsumers: graph.directConsumers.length,
|
|
95
|
+
transitiveConsumers: graph.transitiveConsumers.length,
|
|
96
|
+
affectedFiles: graph.affectedFiles.length,
|
|
97
|
+
cycles: graph.cycles.length,
|
|
98
|
+
scenarioSuites: scenarioSuites.scannedSuites,
|
|
99
|
+
affectedCases: scenarioSuites.matches.length,
|
|
100
|
+
unresolved: graph.unresolved.length + graph.dynamicReferences.length + scenarioSuites.unresolved.length,
|
|
101
|
+
...(comparison === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: {
|
|
104
|
+
addedConsumers: comparison.addedConsumers.length,
|
|
105
|
+
removedConsumers: comparison.removedConsumers.length,
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
result.code = complete ? 'IMPACT_ANALYZED' : 'IMPACT_ANALYZED_PARTIAL';
|
|
109
|
+
result.artifacts = [publicArtifactLink(artifact)];
|
|
110
|
+
result.proposedFiles = overlay?.changes.map(({ path }) => path).slice(0, 64) ?? [];
|
|
111
|
+
result.changedFiles = graph.affectedFiles.slice(0, 64);
|
|
112
|
+
setInlineFilesScanned(result, before.files.map(({ displayPath }) => displayPath));
|
|
113
|
+
result.validation = {
|
|
114
|
+
passed: complete,
|
|
115
|
+
checks: [
|
|
116
|
+
{
|
|
117
|
+
id: 'impact-coverage',
|
|
118
|
+
passed: complete,
|
|
119
|
+
message: complete
|
|
120
|
+
? 'Selected impact graph is complete under the declared bounds'
|
|
121
|
+
: 'Impact graph has source, dynamic-reference, or traversal boundaries; inspect the linked report',
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -5,6 +5,7 @@ import { registerFocusTools } from '../tools/focus.js';
|
|
|
5
5
|
import { registerGuiTools } from '../tools/gui.js';
|
|
6
6
|
import { registerMapTools } from '../tools/map.js';
|
|
7
7
|
import { registerEventTools } from '../tools/event.js';
|
|
8
|
+
import { registerAnalysisTools } from '../tools/analysis.js';
|
|
8
9
|
import { registerTechnologyTools } from '../tools/technology.js';
|
|
9
10
|
import { registerProbabilityTools } from '../tools/probability.js';
|
|
10
11
|
import { registerJobTools } from '../tools/job.js';
|
|
@@ -53,6 +54,7 @@ export function createMcpServer(engine, context = {}) {
|
|
|
53
54
|
registerGuiTools(server, engine, serverContext);
|
|
54
55
|
registerMapTools(server, engine, serverContext);
|
|
55
56
|
registerEventTools(server);
|
|
57
|
+
registerAnalysisTools(server);
|
|
56
58
|
registerTechnologyTools(server);
|
|
57
59
|
registerProbabilityTools(server);
|
|
58
60
|
registerJobTools(server, engine, serverContext);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Shared agent-facing guidance for both MCP protocol generations. */
|
|
2
|
-
export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Concurrent calls queue automatically and emit periodic progress when the client requests it. Long operations support negotiated MCP tasks; native background rewrites require a stable requestKey. Large evidence is linked as resources.';
|
|
2
|
+
export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start cross-system source review with hoi4.impact_inspect and decision or mission scenarios with hoi4.decision_inspect. Test declared mechanic steps with hoi4.mechanic_test, package connections with hoi4.package_check, and named suites with hoi4.scenario_test. These analysis tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Concurrent calls queue automatically and emit periodic progress when the client requests it. Long operations support negotiated MCP tasks; native background rewrites require a stable requestKey. Large evidence is linked as resources.';
|
|
@@ -5,6 +5,11 @@ import { withTaskRequestSignal } from './task-request-context.js';
|
|
|
5
5
|
// remain responsive while every domain-execution slot is occupied.
|
|
6
6
|
const controlTools = new Set(['hoi4.job_inspect', 'hoi4.job_cancel']);
|
|
7
7
|
const backgroundTools = new Set([
|
|
8
|
+
'hoi4.impact_inspect',
|
|
9
|
+
'hoi4.decision_inspect',
|
|
10
|
+
'hoi4.mechanic_test',
|
|
11
|
+
'hoi4.package_check',
|
|
12
|
+
'hoi4.scenario_test',
|
|
8
13
|
'hoi4.event_inspect',
|
|
9
14
|
'hoi4.event_render',
|
|
10
15
|
'hoi4.event_compare',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { analysisTaskTools } from '../tools/analysis.js';
|
|
1
2
|
import { eventTaskTools } from '../tools/event.js';
|
|
2
3
|
import { focusTaskTools } from '../tools/focus.js';
|
|
3
4
|
import { guiTaskTools } from '../tools/gui.js';
|
|
@@ -9,6 +10,7 @@ export const taskToolCatalog = [
|
|
|
9
10
|
...guiTaskTools,
|
|
10
11
|
...mapTaskTools,
|
|
11
12
|
...eventTaskTools,
|
|
13
|
+
...analysisTaskTools,
|
|
12
14
|
...technologyTaskTools,
|
|
13
15
|
...probabilityTaskTools,
|
|
14
16
|
];
|