hoi4-agent-tools 4.0.4 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  5. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  7. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  8. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  9. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  10. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  11. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  12. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  13. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  14. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  15. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  16. package/dist/hoi4_agent_tools/gui/layout.js +61 -25
  17. package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
  18. package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
  19. package/dist/hoi4_agent_tools/gui/studio.js +4 -1
  20. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  21. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  22. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  23. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  24. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  25. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  26. package/dist/hoi4_agent_tools/map/render.js +9 -1
  27. package/dist/hoi4_agent_tools/map/service.js +222 -18
  28. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  29. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  30. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  31. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  32. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  33. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  34. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  35. package/dist/hoi4_agent_tools/map/world.js +374 -253
  36. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  37. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  38. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  39. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  40. package/dist/hoi4_agent_tools/version.js +2 -2
  41. package/docs/gui.md +6 -2
  42. package/docs/images/reference-maps/political.png +0 -0
  43. package/docs/images/reference-maps/sketch.png +0 -0
  44. package/docs/map.md +45 -0
  45. package/docs/third-party-notices.md +6 -0
  46. package/npm-shrinkwrap.json +124 -2
  47. package/package.json +4 -1
  48. package/schemas/configuration.schema.json +1 -1
  49. package/schemas/continuous-focus-palette.schema.json +1 -1
  50. package/schemas/custom-weighted-pool.schema.json +1 -1
  51. package/schemas/decision-inspect-input.schema.json +1 -1
  52. package/schemas/error-log-input.schema.json +1 -1
  53. package/schemas/event-feature-manifest.schema.json +1 -1
  54. package/schemas/focus-plan.schema.json +1 -1
  55. package/schemas/focus-planning-sidecar.schema.json +1 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +373 -70
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/scenario-suite.schema.json +1 -1
  81. package/schemas/scenario-test-input.schema.json +1 -1
  82. package/schemas/script-validate-input.schema.json +1 -1
  83. package/schemas/shared-scenario.schema.json +1 -1
  84. package/schemas/source-lookup-input.schema.json +1 -1
  85. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 4.1.0 - 2026-10-15
6
+
7
+ - Add reference-image `create_world` requests with automatic dimensions, countries, biomes, coastline interpretation, drainage, offline English label recognition, optional aligned layers and reviewed semantic hints.
8
+ - Add `preview: true` to map rewrites. Previews validate and render without creating or applying a rewrite transaction; failed generation is never illustrated using the unchanged base-game map.
9
+ - Guard local reference inputs with canonical workspace checks, bounded decoding, fixed-memory original-source hashing and stale-source verification. OCR uses packaged local model data and bounded worker lifetimes.
10
+ - Generate smoother relief, connected terrain-dependent provinces and downhill rivers; use installed palette conventions, distinct country filenames and actual urban footprints.
11
+ - Cache map colors per province instead of hashing them for every rendered pixel. Retain deterministic pixels and artifact contents.
12
+ - Preserve reviewed city, state, sea and region names and report uncertain interpretation. Map generation remains an offline foundation, with live acceptance evidence reported separately.
13
+ - Add starter bookmarks, country histories, infantry templates, units, stockpiles and production; isolate old-world AI and startup hooks while retaining shared continent identifiers.
14
+ - Track generated outputs and safely remove obsolete files on regeneration; reject edited outputs and malformed inventories. Bound complete-world inline diffs so preview links remain available.
15
+ - Clean detached water speckles without turning lake islands into ocean pockets, preserve native province extent limits, and emit building positions without a spurious final record.
16
+
17
+ ## 4.0.5 - 2026-10-14
18
+
19
+ - Keep child anchors independent of parent `origo`, so centered native windows place their controls inside the frame.
20
+ - Preview division and HQ branches separately and close native selector overlays by default. Existing scenario values and visibility bindings select panels explicitly.
21
+ - Preserve dropdown button and expanded-panel boundaries, including closed visibility, settled positions, backgrounds, clipping and attached scrollbars.
22
+ - Render background-free scroll containers using their declared viewport bounds; retain findings for explicitly disabled clipping and invalid boundaries or references.
23
+ - Resolve public scrollbar templates despite unrelated private namesakes. Public tool schemas and dependency versions are unchanged.
24
+
5
25
  ## 4.0.4 - 2026-10-14
6
26
 
7
27
  - Preview individual GUI windows without redundant full symbol indexes, workspace-wide asset fallback or unbound country-flag inventories; reuse condition definitions across render variants.
package/README.md CHANGED
@@ -17,6 +17,8 @@ Make focus trees, events and decisions. Edit maps and generate a new world. Prev
17
17
  ## What it does
18
18
 
19
19
  Your agent can build and edit focus trees, events, decisions and maps, and generate a new world.
20
+
21
+ For a new map, give it a cleaned local reference image and preview the interpreted geography before writing. The [map workflow](docs/map.md#generate-a-world-from-one-image) creates provinces, states, terrain, cities, countries, supply and a provisional starting scenario. Reviewed names and masks refine ambiguous artwork; offline validation is separate from [native campaign acceptance](https://github.com/klimPaskov/hoi4-agent-tools/blob/v4.1.0/docs/evaluations/worldgen_acceptance_2026-10-15.md).
20
22
  It previews focus trees, technology folders, windows and maps, finds the right rule in the game's documentation, fixes broken script and tunes AI choices.
21
23
  You describe what you want; the agent picks the right tools.
22
24
 
@@ -0,0 +1,210 @@
1
+ import { ServiceError } from './result.js';
2
+ import { prepareSource, verifySource } from './scanner.js';
3
+ import { isWithin } from './workspace.js';
4
+ import { createHash } from 'node:crypto';
5
+ import { lstat, mkdtemp, open, realpath, rmdir, unlink } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import path from 'node:path';
8
+ import sharp from 'sharp';
9
+ import { TemporaryDirectoryLifetime, sweepTemporaryDirectories } from './temporary-directories.js';
10
+ export const REFERENCE_SOURCE_MAX_BYTES = 1_073_741_824;
11
+ export const REFERENCE_WORKING_MAX_SIDE = 3_072;
12
+ export const REFERENCE_WORKING_MAX_BYTES = 33_554_432;
13
+ function supportedRasterHeader(bytes) {
14
+ return ((bytes.length >= 8 &&
15
+ [137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => bytes[index] === value)) ||
16
+ (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) ||
17
+ (bytes.length >= 12 &&
18
+ [82, 73, 70, 70].every((value, index) => bytes[index] === value) &&
19
+ [87, 69, 66, 80].every((value, index) => bytes[index + 8] === value)));
20
+ }
21
+ function assertSnapshotIsolation(resolver, candidate, checkDescendants) {
22
+ for (const workspace of resolver.list())
23
+ for (const root of [
24
+ ...workspace.roots,
25
+ { path: workspace.artifactRoot },
26
+ { path: workspace.cacheRoot },
27
+ ])
28
+ if (isWithin(root.path, candidate) || (checkDescendants && isWithin(candidate, root.path)))
29
+ throw new ServiceError('REFERENCE_SNAPSHOT_UNSAFE', 'Reference snapshots must be outside workspace capability roots');
30
+ }
31
+ async function assertSnapshotDirectory(resolver, directory, parent) {
32
+ const canonical = path.normalize(await realpath(directory));
33
+ const metadata = await lstat(directory);
34
+ if (canonical !== path.normalize(directory) ||
35
+ !isWithin(parent, canonical) ||
36
+ canonical === parent ||
37
+ metadata.isSymbolicLink() ||
38
+ !metadata.isDirectory())
39
+ throw new ServiceError('REFERENCE_SNAPSHOT_UNSAFE', 'Reference snapshot directory is not a private canonical directory');
40
+ if (process.platform !== 'win32' &&
41
+ ((metadata.mode & 0o077) !== 0 ||
42
+ (process.getuid !== undefined && metadata.uid !== process.getuid())))
43
+ throw new ServiceError('REFERENCE_SNAPSHOT_UNSAFE', 'Reference snapshot directory permissions are not private');
44
+ assertSnapshotIsolation(resolver, canonical, true);
45
+ }
46
+ async function createPrivateSnapshot(resolver) {
47
+ const parent = path.normalize(await realpath(resolver.serverState()?.root ?? tmpdir()));
48
+ assertSnapshotIsolation(resolver, parent, false);
49
+ const parentMetadata = await lstat(parent);
50
+ if (!parentMetadata.isDirectory() ||
51
+ parentMetadata.isSymbolicLink() ||
52
+ (process.platform !== 'win32' &&
53
+ (parentMetadata.mode & 0o022) !== 0 &&
54
+ (parentMetadata.mode & 0o1000) === 0))
55
+ throw new ServiceError('REFERENCE_SNAPSHOT_UNSAFE', 'Reference snapshot parent is not protected against directory replacement');
56
+ // Hidden directories are excluded by mod-root discovery even with a temporary-root fallback.
57
+ await sweepTemporaryDirectories(parent, {
58
+ referenceSnapshotsOnly: true,
59
+ staleMs: 0,
60
+ maxBytes: REFERENCE_SOURCE_MAX_BYTES,
61
+ protectedRoots: resolver
62
+ .list()
63
+ .flatMap((workspace) => [
64
+ ...workspace.roots.map(({ path }) => path),
65
+ workspace.artifactRoot,
66
+ workspace.cacheRoot,
67
+ ]),
68
+ });
69
+ const directory = await mkdtemp(path.join(parent, '.reference-snapshot-'));
70
+ const lifetime = new TemporaryDirectoryLifetime(REFERENCE_SOURCE_MAX_BYTES);
71
+ try {
72
+ await assertSnapshotDirectory(resolver, directory, parent);
73
+ await lifetime.own(directory);
74
+ const filePath = path.join(directory, 'source.raster');
75
+ const handle = await open(filePath, 'wx', 0o600);
76
+ try {
77
+ await assertSnapshotDirectory(resolver, directory, parent);
78
+ return { directory, filePath, handle, closed: false, lifetime };
79
+ }
80
+ catch (error) {
81
+ await handle.close();
82
+ await unlink(filePath);
83
+ throw error;
84
+ }
85
+ }
86
+ catch (error) {
87
+ await lifetime.dispose();
88
+ if (await lstat(directory).catch(() => undefined))
89
+ await rmdir(directory);
90
+ throw error;
91
+ }
92
+ }
93
+ async function removePrivateSnapshot(snapshot) {
94
+ try {
95
+ let closeFailed = false;
96
+ try {
97
+ if (!snapshot.closed)
98
+ await snapshot.handle.close();
99
+ }
100
+ catch {
101
+ closeFailed = true;
102
+ }
103
+ await snapshot.lifetime.dispose();
104
+ if (closeFailed || (await lstat(snapshot.directory).catch(() => undefined)))
105
+ throw new Error('Private snapshot ownership changed before cleanup');
106
+ }
107
+ catch {
108
+ throw new ServiceError('REFERENCE_SNAPSHOT_CLEANUP_FAILED', 'Private reference snapshot cleanup failed');
109
+ }
110
+ }
111
+ /** Decode a private snapshot copied and hashed from the same verified FD, using fixed memory. */
112
+ export async function readReferenceImageSource(resolver, workspaceId, relativePath, principal, signal) {
113
+ if (!/\.(?:png|jpe?g|webp)$/iu.test(relativePath) || relativePath.includes(':'))
114
+ throw new ServiceError('REFERENCE_IMAGE_PATH_INVALID', 'Reference must name a local raster image');
115
+ const resolved = await resolver.resolvePath(workspaceId, relativePath, 'read', ['mod'], principal);
116
+ const source = await prepareSource(resolved.root, relativePath, signal);
117
+ let snapshot;
118
+ try {
119
+ if (!source.metadata.isFile())
120
+ throw new ServiceError('SOURCE_NOT_REGULAR_FILE', 'Reference source must be a regular file');
121
+ if (source.metadata.size < 1 || source.metadata.size > REFERENCE_SOURCE_MAX_BYTES)
122
+ throw new ServiceError('SOURCE_BYTE_LIMIT', 'Reference source exceeds its byte limit', {
123
+ limit: REFERENCE_SOURCE_MAX_BYTES,
124
+ });
125
+ signal?.throwIfAborted();
126
+ snapshot = await createPrivateSnapshot(resolver);
127
+ const hash = createHash('sha256');
128
+ const chunk = Buffer.alloc(Math.min(1_048_576, source.metadata.size));
129
+ let offset = 0;
130
+ while (offset < source.metadata.size) {
131
+ signal?.throwIfAborted();
132
+ const read = await source.handle.read(chunk, 0, Math.min(chunk.length, source.metadata.size - offset), offset);
133
+ if (read.bytesRead === 0)
134
+ throw new ServiceError('SCAN_SOURCE_CHANGED', 'Reference source changed while copying its snapshot');
135
+ if (offset === 0 && !supportedRasterHeader(chunk.subarray(0, read.bytesRead)))
136
+ throw new ServiceError('REFERENCE_IMAGE_DECODE_FAILED', 'Reference source is not a supported raster image');
137
+ hash.update(chunk.subarray(0, read.bytesRead));
138
+ let written = 0;
139
+ while (written < read.bytesRead) {
140
+ const result = await snapshot.handle.write(chunk, written, read.bytesRead - written, offset + written);
141
+ if (result.bytesWritten === 0)
142
+ throw new ServiceError('REFERENCE_SNAPSHOT_WRITE_FAILED', 'Reference snapshot could not be completed');
143
+ written += result.bytesWritten;
144
+ }
145
+ offset += read.bytesRead;
146
+ }
147
+ const originalHash = hash.digest('hex');
148
+ await snapshot.handle.close();
149
+ snapshot.closed = true;
150
+ await verifySource(resolved.root, source);
151
+ signal?.throwIfAborted();
152
+ sharp.cache({ memory: 32, files: 0, items: 32 });
153
+ const input = sharp(snapshot.filePath, {
154
+ sequentialRead: true,
155
+ limitInputPixels: 300_000_000,
156
+ failOn: 'warning',
157
+ });
158
+ const metadata = await input.metadata();
159
+ if (!metadata.width ||
160
+ !metadata.height ||
161
+ (metadata.pages ?? 1) > 1 ||
162
+ !['png', 'jpeg', 'webp'].includes(metadata.format))
163
+ throw new ServiceError('REFERENCE_IMAGE_UNSUPPORTED', 'Reference must be a single-frame supported raster image');
164
+ signal?.throwIfAborted();
165
+ const decoded = await input
166
+ .resize({
167
+ width: REFERENCE_WORKING_MAX_SIDE,
168
+ height: REFERENCE_WORKING_MAX_SIDE,
169
+ fit: 'inside',
170
+ withoutEnlargement: true,
171
+ })
172
+ .png()
173
+ .toBuffer({ resolveWithObject: true });
174
+ await verifySource(resolved.root, source);
175
+ signal?.throwIfAborted();
176
+ if (decoded.data.length > REFERENCE_WORKING_MAX_BYTES)
177
+ throw new ServiceError('REFERENCE_WORKING_BYTE_LIMIT', 'Reference working image exceeds its byte limit');
178
+ return {
179
+ source: {
180
+ absolutePath: source.canonicalPath,
181
+ displayPath: `mod:${relativePath.replaceAll('\\', '/')}`,
182
+ relativePath: relativePath.replaceAll('\\', '/'),
183
+ rootKind: resolved.root.kind,
184
+ loadOrder: resolved.root.loadOrder,
185
+ size: source.metadata.size,
186
+ modifiedMs: source.metadata.mtimeMs,
187
+ sha256: originalHash,
188
+ },
189
+ bytes: decoded.data,
190
+ originalWidth: metadata.width,
191
+ originalHeight: metadata.height,
192
+ workingWidth: decoded.info.width,
193
+ workingHeight: decoded.info.height,
194
+ };
195
+ }
196
+ catch (error) {
197
+ if (error instanceof ServiceError || signal?.aborted)
198
+ throw error;
199
+ throw new ServiceError('REFERENCE_IMAGE_DECODE_FAILED', 'Reference image could not be safely decoded');
200
+ }
201
+ finally {
202
+ try {
203
+ if (snapshot !== undefined)
204
+ await removePrivateSnapshot(snapshot);
205
+ }
206
+ finally {
207
+ await source.handle.close();
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,91 @@
1
+ import { canonicalJson, compareCodeUnits } from './canonical.js';
2
+ import { sortDiagnostics } from './diagnostics.js';
3
+ const SAMPLE_LIMIT = 8;
4
+ /** Count source occurrences represented by an original diagnostic or a soft rollup. */
5
+ export function diagnosticOccurrenceCount(diagnostic) {
6
+ const value = diagnostic.details?.occurrenceCount;
7
+ return diagnostic.details?.rollup === 'repeated-soft-diagnostics.v1' &&
8
+ typeof value === 'number' &&
9
+ Number.isSafeInteger(value) &&
10
+ value > 0
11
+ ? value
12
+ : 1;
13
+ }
14
+ /** Include omission evidence emitted by bounded validators without counting their marker twice. */
15
+ export function diagnosticSeverityCounts(diagnostics) {
16
+ const counts = { info: 0, warning: 0, error: 0, blocker: 0 };
17
+ for (const diagnostic of diagnostics) {
18
+ if (diagnostic.code !== 'MAP_DIAGNOSTICS_TRUNCATED') {
19
+ counts[diagnostic.severity] += diagnosticOccurrenceCount(diagnostic);
20
+ continue;
21
+ }
22
+ const omissions = diagnostic.details?.omittedBySeverity;
23
+ if (typeof omissions !== 'object' || omissions === null)
24
+ continue;
25
+ for (const severity of ['info', 'warning', 'error', 'blocker']) {
26
+ const value = omissions[severity];
27
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
28
+ counts[severity] += value;
29
+ }
30
+ }
31
+ return counts;
32
+ }
33
+ /** Keep every hard diagnostic; summarize repeated informational and warning codes only. */
34
+ export function rollupRepeatedSoftDiagnostics(diagnostics, evidence) {
35
+ const hard = [];
36
+ const groups = new Map();
37
+ const ordered = diagnostics
38
+ .map((diagnostic) => ({ diagnostic, key: canonicalJson(diagnostic) }))
39
+ .sort((left, right) => compareCodeUnits(left.key, right.key))
40
+ .map(({ diagnostic }) => diagnostic);
41
+ for (const diagnostic of ordered) {
42
+ if (diagnostic.severity === 'error' ||
43
+ diagnostic.severity === 'blocker' ||
44
+ diagnostic.code === 'MAP_DIAGNOSTICS_TRUNCATED') {
45
+ hard.push(diagnostic);
46
+ continue;
47
+ }
48
+ const key = `${diagnostic.severity}:${diagnostic.code}`;
49
+ const group = groups.get(key) ?? [];
50
+ group.push(diagnostic);
51
+ groups.set(key, group);
52
+ }
53
+ const soft = [...groups.values()].map((group) => {
54
+ if (group.length === 1)
55
+ return group[0];
56
+ const first = group[0];
57
+ const occurrences = group.reduce((total, diagnostic) => total + diagnosticOccurrenceCount(diagnostic), 0);
58
+ const locations = new Map();
59
+ for (const diagnostic of group)
60
+ for (const location of [diagnostic.location, ...(diagnostic.related ?? [])])
61
+ if (location !== undefined)
62
+ locations.set(canonicalJson(location), location);
63
+ const samples = [...locations]
64
+ .sort(([left], [right]) => compareCodeUnits(left, right))
65
+ .slice(0, SAMPLE_LIMIT)
66
+ .map(([, location]) => location);
67
+ const operationIds = [
68
+ ...new Set(group.flatMap(({ operationId }) => (operationId === undefined ? [] : [operationId]))),
69
+ ].sort(compareCodeUnits);
70
+ return {
71
+ code: first.code,
72
+ severity: first.severity,
73
+ category: first.category,
74
+ message: `${occurrences} occurrences of ${first.code}; ${first.message}`.slice(0, 4096),
75
+ ...(samples[0] === undefined ? {} : { location: samples[0] }),
76
+ ...(samples.length < 2 ? {} : { related: samples.slice(1) }),
77
+ ...(operationIds.length === 1 ? { operationId: operationIds[0] } : {}),
78
+ details: {
79
+ rollup: 'repeated-soft-diagnostics.v1',
80
+ occurrenceCount: occurrences,
81
+ retainedSamples: Math.min(SAMPLE_LIMIT, group.length),
82
+ samples: group.slice(0, SAMPLE_LIMIT),
83
+ operationCount: operationIds.length,
84
+ operationIds: operationIds.slice(0, SAMPLE_LIMIT),
85
+ locationCount: locations.size,
86
+ ...(evidence === undefined ? {} : { completeDiagnosticEvidence: evidence }),
87
+ },
88
+ };
89
+ });
90
+ return sortDiagnostics([...hard, ...soft]);
91
+ }
@@ -1,6 +1,7 @@
1
1
  import { boundedSourceHashEvidence, publicArtifactLink } from './artifacts.js';
2
2
  import { canonicalJson } from './canonical.js';
3
3
  import { exactChangedFilePatterns, focusDomainScanPatterns, mapChangeScanPatterns, } from './domain-scan-patterns.js';
4
+ import { rollupRepeatedSoftDiagnostics } from './diagnostic-rollup.js';
4
5
  import { parseClausewitz } from './source/index.js';
5
6
  import { TRANSACTION_MAX_DIAGNOSTICS } from './transaction-limits.js';
6
7
  import { importContinuousFocusPalettes, importFocusTrees, layoutFocusTreeAsync, lintContinuousFocusPalette, lintFocusTree, } from '../focus/index.js';
@@ -158,6 +159,17 @@ export async function postValidateTransaction(engine, transaction, principal, si
158
159
  .slice(0, retainedLimit - retainedHard.length);
159
160
  const omittedHard = hard.length - retainedHard.length;
160
161
  const artifactLink = publicArtifactLink(artifact);
162
+ if (transaction.operationKind === 'agent-nudger-map-changes') {
163
+ const rolled = rollupRepeatedSoftDiagnostics(diagnostics, artifactLink);
164
+ if (rolled.length <= availableDiagnostics) {
165
+ checks.push({
166
+ id: 'post-write-diagnostics-artifact',
167
+ passed: true,
168
+ message: `Complete bounded post-write diagnostics are stored at ${artifactLink.uri}`,
169
+ });
170
+ return { diagnostics: rolled, checks, artifacts: [artifactLink] };
171
+ }
172
+ }
161
173
  checks.push({
162
174
  id: 'post-write-diagnostics-artifact',
163
175
  passed: omittedHard === 0,
@@ -10,6 +10,7 @@ import { RequestScheduler } from './request-scheduler.js';
10
10
  import { SharedRequestCapacity } from './shared-request-capacity.js';
11
11
  import { JobStore } from './job-store.js';
12
12
  import { ServiceError } from './result.js';
13
+ import { TRANSACTION_MAX_JOURNAL_BYTES } from './transaction-limits.js';
13
14
  /**
14
15
  * Completed scans kept per workspace. One tool call often scans several pattern sets (a GUI
15
16
  * preview scans layouts, linked definitions and localisation), so a single slot made every
@@ -17,7 +18,7 @@ import { ServiceError } from './result.js';
17
18
  */
18
19
  const SCAN_CACHE_ENTRIES_PER_WORKSPACE = 4;
19
20
  const RECOVERY_TTL_SECONDS = 3_600;
20
- const RECOVERY_MAX_BYTES = 536_870_912;
21
+ const RECOVERY_MAX_BYTES = TRANSACTION_MAX_JOURNAL_BYTES;
21
22
  const RECOVERY_MAX_RECORDS = 128;
22
23
  function abortReason(signal) {
23
24
  return signal.reason instanceof Error
@@ -34,6 +34,8 @@ export class JobOperations {
34
34
  mutation: true,
35
35
  prepare: async (input, context) => {
36
36
  const prepared = await prepare(schema.parse(input), context);
37
+ if ('readOnlyPreview' in prepared)
38
+ return { readOnlyPreview: jsonObject.parse(prepared.readOnlyPreview) };
37
39
  if (!('transaction' in prepared))
38
40
  return { transaction: prepared };
39
41
  return {
@@ -161,6 +163,13 @@ export class JobExecutor {
161
163
  if (claimed.record.transaction !== undefined)
162
164
  return writes.reconcile(workspaceId, id, owner.token, principal, undefined, true, (execution, reconciled, recipe) => operation.complete(initial.request.arguments, execution, reconciled, recipe, context));
163
165
  const prepared = await operation.prepare(initial.request.arguments, context);
166
+ if ('readOnlyPreview' in prepared) {
167
+ controller.signal.throwIfAborted();
168
+ return this.jobs.store.updateOwned(scope, id, owner.token, {
169
+ status: 'completed',
170
+ result: prepared.readOnlyPreview,
171
+ });
172
+ }
164
173
  return writes.execute(workspaceId, id, owner.token, prepared.transaction, principal, controller.signal, prepared.recipe, (execution, reconciled, recipe) => operation.complete(initial.request.arguments, execution, reconciled, recipe, context));
165
174
  }
166
175
  const result = jsonObject.parse(await withAnalysisCheckpoints(jobAnalysisCheckpoints(context), () => operation.run(initial.request.arguments, context)));
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import { canonicalJson, compareCodeUnits, sha256Bytes } from './canonical.js';
3
+ import { ServiceError } from './result.js';
4
+ const manifestSchema = z
5
+ .object({
6
+ version: z.literal(1),
7
+ files: z
8
+ .array(z
9
+ .object({
10
+ relativePath: z.string().min(1).max(512),
11
+ sha256: z.string().regex(/^[a-f0-9]{64}$/u),
12
+ })
13
+ .strict())
14
+ .max(5000),
15
+ })
16
+ .strict();
17
+ /** Pure planning only: the shared transaction engine performs every replacement and deletion. */
18
+ export function reconcileManagedOutputs(input) {
19
+ const sources = new Map(input.sources
20
+ .filter((f) => f.rootKind === 'mod')
21
+ .map((f) => [f.relativePath.replaceAll('\\', '/').toLowerCase(), f]));
22
+ const manifest = sources.get(input.manifestPath.toLowerCase());
23
+ const previous = manifest === undefined
24
+ ? []
25
+ : (() => {
26
+ if (manifest.bytes.length > 2_000_000)
27
+ throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory exceeds its byte limit');
28
+ try {
29
+ return manifestSchema.parse(JSON.parse(manifest.bytes.toString('utf8'))).files;
30
+ }
31
+ catch {
32
+ throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory is invalid');
33
+ }
34
+ })();
35
+ const seen = new Set();
36
+ for (const file of previous) {
37
+ const key = file.relativePath.toLowerCase();
38
+ if (file.relativePath.includes('\\') ||
39
+ file.relativePath.split('/').some((s) => !s || s === '.' || s === '..') ||
40
+ /^[a-z]:/iu.test(file.relativePath) ||
41
+ !input.permitsPath(file.relativePath) ||
42
+ seen.has(key))
43
+ throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory contains an unsupported or duplicate path');
44
+ seen.add(key);
45
+ const current = sources.get(key);
46
+ if (current !== undefined && sha256Bytes(current.bytes) !== file.sha256)
47
+ throw new ServiceError('MANAGED_OUTPUT_EDITED', 'A generated file has been edited; regeneration would discard those changes', { relativePath: file.relativePath });
48
+ }
49
+ const outputs = input.proposed.filter((f) => f.content !== null && input.permitsPath(f.relativePath));
50
+ const next = new Set(outputs.map((f) => f.relativePath.toLowerCase()));
51
+ const deletions = previous
52
+ .filter((f) => !next.has(f.relativePath.toLowerCase()) && sources.has(f.relativePath.toLowerCase()))
53
+ .map((f) => ({
54
+ relativePath: f.relativePath,
55
+ content: null,
56
+ operationIds: [input.operationId],
57
+ }));
58
+ return [
59
+ ...deletions,
60
+ {
61
+ relativePath: input.manifestPath,
62
+ content: Buffer.from(canonicalJson({
63
+ version: 1,
64
+ files: outputs
65
+ .map((f) => ({ relativePath: f.relativePath, sha256: sha256Bytes(f.content) }))
66
+ .sort((a, b) => compareCodeUnits(a.relativePath, b.relativePath)),
67
+ }) + '\n'),
68
+ operationIds: [input.operationId],
69
+ mediaType: 'application/json',
70
+ },
71
+ ];
72
+ }
@@ -500,10 +500,36 @@ export function errorResult(error, workspaceId = '', context = {}) {
500
500
  ? `REWRITE_${serviceError.code.slice('TRANSACTION_'.length)}`
501
501
  : serviceError.code;
502
502
  const publicMessage = internalWriteError
503
- ? 'Rewrite could not be completed'
503
+ ? serviceError.code === 'TRANSACTION_STRUCTURE_LIMIT'
504
+ ? 'Rewrite exceeds the supported file, operation, validation, or evidence limits'
505
+ : serviceError.code === 'TRANSACTION_JOURNAL_LIMIT'
506
+ ? 'The requested rewrite exceeds the bounded rollback storage allowance'
507
+ : 'Rewrite could not be completed'
504
508
  : serviceError.message;
505
509
  const publicDetails = internalWriteError
506
- ? {}
510
+ ? ['TRANSACTION_STRUCTURE_LIMIT', 'TRANSACTION_JOURNAL_LIMIT'].includes(serviceError.code)
511
+ ? Object.fromEntries(Object.entries(serviceError.details).filter(([key, value]) => [
512
+ 'files',
513
+ 'filesLimit',
514
+ 'operations',
515
+ 'operationsLimit',
516
+ 'readDependencies',
517
+ 'readDependenciesLimit',
518
+ 'artifacts',
519
+ 'artifactsLimit',
520
+ 'diagnostics',
521
+ 'diagnosticsLimit',
522
+ 'checks',
523
+ 'checksLimit',
524
+ 'requestedWorkBytes',
525
+ 'plannedJournalBytes',
526
+ 'retainedBytes',
527
+ 'maximumBytes',
528
+ ].includes(key) &&
529
+ typeof value === 'number' &&
530
+ Number.isSafeInteger(value) &&
531
+ value >= 0))
532
+ : {}
507
533
  : Object.fromEntries(Object.entries(serviceError.details).filter(([key]) => ![
508
534
  'cause',
509
535
  'stack',
@@ -1,4 +1,5 @@
1
1
  import { open, realpath, stat } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import { iterateFiles } from './file-glob.js';
4
5
  import { compareCodeUnits, sha256Bytes } from './canonical.js';
@@ -7,6 +8,22 @@ import { ServiceError } from './result.js';
7
8
  import { isWithin } from './workspace.js';
8
9
  const SCAN_IO_CONCURRENCY = 8;
9
10
  const SCAN_READ_BATCH_BYTES = 16_777_216;
11
+ /** Hash an open source using fixed memory; callers verify its canonical identity afterwards. */
12
+ export async function hashSourceBytes(source, signal) {
13
+ const hash = createHash('sha256');
14
+ const chunk = Buffer.alloc(Math.max(1, Math.min(1_048_576, source.metadata.size)));
15
+ let offset = 0;
16
+ while (offset < source.metadata.size) {
17
+ signal?.throwIfAborted();
18
+ const read = await source.handle.read(chunk, 0, Math.min(chunk.length, source.metadata.size - offset), offset);
19
+ if (!read.bytesRead)
20
+ throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source changed while being hashed');
21
+ hash.update(chunk.subarray(0, read.bytesRead));
22
+ offset += read.bytesRead;
23
+ }
24
+ signal?.throwIfAborted();
25
+ return hash.digest('hex');
26
+ }
10
27
  export async function verifySource(root, source) {
11
28
  const resolved = await realpath(source.absolutePath);
12
29
  if (!isWithin(root.path, resolved))
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { z } from 'zod/v4';
5
5
  import { compareCodeUnits, canonicalJson, secureId } from './canonical.js';
6
6
  import { ServiceError } from './result.js';
7
+ import { TRANSACTION_MAX_MANIFEST_BYTES } from './transaction-limits.js';
7
8
  const journalKeyFile = 'journal-hmac.key';
8
9
  const journalKeyBytes = 32;
9
10
  const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
@@ -157,7 +158,14 @@ export class ServerState {
157
158
  return new ServerState(canonicalRoot, Buffer.from(key));
158
159
  }
159
160
  authenticateJournal(payload) {
160
- return createHmac('sha256', this.journalKey).update(canonicalJson(payload)).digest('hex');
161
+ return this.authenticateCanonicalJournal(canonicalJson(payload));
162
+ }
163
+ /** Internal canonical-byte path; the signature is identical to authenticateJournal. */
164
+ authenticateCanonicalJournal(payload) {
165
+ if (Buffer.byteLength(payload, 'utf8') > TRANSACTION_MAX_MANIFEST_BYTES) {
166
+ throw stateError('TRANSACTION_MANIFEST_LIMIT', 'Transaction manifest exceeds its fixed byte limit');
167
+ }
168
+ return createHmac('sha256', this.journalKey).update(payload).digest('hex');
161
169
  }
162
170
  verifyJournal(payload, authenticationTag) {
163
171
  if (!/^[a-f0-9]{64}$/u.test(authenticationTag))
@@ -10,6 +10,7 @@ export const TEMPORARY_MAX_BYTES = 256 * 1024 * 1024;
10
10
  export const TEMPORARY_STALE_MS = 3 * 60 * 60 * 1000;
11
11
  // Dated event evaluation directories contain retained benchmark evidence, not scratch files.
12
12
  const scratchName = /^hoi4-(?:reference-source|event-eval)-[A-Za-z0-9]{6}$/u;
13
+ const referenceSnapshotName = /^\.reference-snapshot-[A-Za-z0-9]{6}$/u;
13
14
  const ownerSchema = z
14
15
  .object({
15
16
  version: z.literal(1),
@@ -100,7 +101,8 @@ export async function sweepTemporaryDirectories(parent, options = {}) {
100
101
  }
101
102
  }))).flat();
102
103
  const candidates = [];
103
- for (const name of (await readdir(root)).filter((name) => scratchName.test(name)).sort()) {
104
+ const names = options.referenceSnapshotsOnly ? referenceSnapshotName : scratchName;
105
+ for (const name of (await readdir(root)).filter((name) => names.test(name)).sort()) {
104
106
  const directory = path.join(root, name);
105
107
  if (protectedRoots.some((protectedRoot) => overlaps(directory, protectedRoot)))
106
108
  continue;
@@ -112,6 +114,8 @@ export async function sweepTemporaryDirectories(parent, options = {}) {
112
114
  (await realpath(directory)) !== directory)
113
115
  continue;
114
116
  const record = await owner(directory);
117
+ if (options.referenceSnapshotsOnly && record == null)
118
+ continue;
115
119
  // An interrupted sweep must not leave a permanent lock. Malformed or live locks
116
120
  // stay protected; only a confirmed dead local owner can relinquish its lock.
117
121
  const previousSweep = await owner(directory, SWEEP_LOCK);
@@ -0,0 +1,43 @@
1
+ import { canonicalJson, compareCodeUnits } from './canonical.js';
2
+ import { ServiceError } from './result.js';
3
+ import { TRANSACTION_MAX_MANIFEST_BYTES } from './transaction-limits.js';
4
+ /** A request-local snapshot of canonical immutable fields, without shared writable references. */
5
+ export class TransactionCanonicalSnapshot {
6
+ immutable = new Map();
7
+ mutable;
8
+ constructor(source, mutableKeys) {
9
+ this.mutable = new Set(mutableKeys);
10
+ let bytes = 2;
11
+ for (const [key, value] of Object.entries(source)) {
12
+ if (this.mutable.has(key) || value === undefined)
13
+ continue;
14
+ const encoded = canonicalJson(value);
15
+ bytes += Buffer.byteLength(JSON.stringify(key)) + Buffer.byteLength(encoded) + 2;
16
+ this.assertBound(bytes);
17
+ this.immutable.set(key, encoded);
18
+ }
19
+ }
20
+ serialize(source, excludedKeys = []) {
21
+ const mutable = source;
22
+ const excluded = new Set(excludedKeys);
23
+ const keys = [...new Set([...this.immutable.keys(), ...this.mutable])]
24
+ .filter((key) => !excluded.has(key) && (this.immutable.has(key) || mutable[key] !== undefined))
25
+ .sort(compareCodeUnits);
26
+ // JSON objects emit integer keys before other keys, including after canonical insertion.
27
+ const orderedKeys = Object.keys(Object.fromEntries(keys.map((key) => [key, null])));
28
+ let bytes = 2;
29
+ const entries = orderedKeys.map((key) => {
30
+ const value = this.immutable.get(key) ?? canonicalJson(mutable[key]);
31
+ const entry = `${JSON.stringify(key)}:${value}`;
32
+ bytes += Buffer.byteLength(entry) + 1;
33
+ this.assertBound(bytes);
34
+ return entry;
35
+ });
36
+ return `{${entries.join(',')}}`;
37
+ }
38
+ assertBound(bytes) {
39
+ if (bytes > TRANSACTION_MAX_MANIFEST_BYTES) {
40
+ throw new ServiceError('TRANSACTION_MANIFEST_LIMIT', 'Transaction manifest exceeds its fixed byte limit');
41
+ }
42
+ }
43
+ }