modwright 0.1.1 → 0.1.3

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 (65) hide show
  1. package/README.md +123 -15
  2. package/bridges/bg3-se/README.md +15 -0
  3. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
  4. package/bridges/cp2077-cet/README.md +17 -1
  5. package/bridges/cp2077-cet/bridge.json +1 -1
  6. package/dist/core/bridge/pending.js +8 -1
  7. package/dist/core/bridge/stage.js +2 -2
  8. package/dist/core/build/run.js +14 -0
  9. package/dist/core/build/stage.js +3 -2
  10. package/dist/core/build/steps/core.js +4 -4
  11. package/dist/core/deploy/plan.js +68 -1
  12. package/dist/core/deploy/reload.js +5 -3
  13. package/dist/core/ini.js +26 -0
  14. package/dist/core/knowledge/facts.js +0 -12
  15. package/dist/core/ledger/hooks.js +22 -5
  16. package/dist/core/logs/registry.js +19 -0
  17. package/dist/core/logs/scan.js +65 -10
  18. package/dist/core/logs/triage.js +79 -2
  19. package/dist/core/managers/mo2.js +121 -0
  20. package/dist/core/managers/vortex.js +52 -0
  21. package/dist/core/ownership/index.js +364 -0
  22. package/dist/core/project/index.js +2 -1
  23. package/dist/core/project/load.js +7 -25
  24. package/dist/core/project/outside-root.js +36 -0
  25. package/dist/core/project/schema.js +38 -1
  26. package/dist/core/safety/backup.js +2 -2
  27. package/dist/core/safety/execute.js +12 -4
  28. package/dist/core/safety/restore.js +62 -0
  29. package/dist/core/testplan/registry.js +51 -2
  30. package/dist/core/text/converters/index.js +1 -1
  31. package/dist/core/text/toolchain.js +2 -2
  32. package/dist/core/toolchain/locate.js +13 -1
  33. package/dist/core/toolchain/run.js +6 -4
  34. package/dist/core/toolchain/types.js +1 -1
  35. package/dist/core/trust.js +105 -0
  36. package/dist/core/writes/index.js +206 -0
  37. package/dist/index.js +33 -9
  38. package/dist/server.js +252 -34
  39. package/dist/surfaces/baldursgate3/surface.js +3 -0
  40. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  41. package/dist/surfaces/baldursgate3/writes.js +152 -0
  42. package/dist/surfaces/cyberpunk2077/compat.js +8 -4
  43. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  44. package/dist/surfaces/cyberpunk2077/logs.js +86 -6
  45. package/dist/surfaces/cyberpunk2077/surface.js +13 -1
  46. package/dist/surfaces/cyberpunk2077/validators/cr2w.js +9 -3
  47. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  48. package/dist/surfaces/eldenring/loaders.js +7 -1
  49. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  50. package/dist/surfaces/skyrimse/surface.js +1 -0
  51. package/dist/surfaces/stardewvalley/logs.js +41 -0
  52. package/dist/surfaces/stardewvalley/surface.js +2 -0
  53. package/dist/surfaces/stardewvalley/writes.js +136 -0
  54. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  55. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  56. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  57. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  58. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  59. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  60. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  61. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  62. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  63. package/package.json +1 -1
  64. package/scaffolds/baldursgate3/new-project.yaml +4 -1
  65. package/scaffolds/cyberpunk2077/new-project.yaml +4 -1
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
8
8
  const SERVER_ENTRY = process.env.MODWRIGHT_SERVER_ENTRY ?? path.join(here, "server.js");
9
9
  const STAMP_FILE = process.env.MODWRIGHT_BUILD_STAMP ?? path.join(here, ".build-stamp");
10
10
  const STAMP_POLL_MS = Number(process.env.MODWRIGHT_STAMP_POLL_MS ?? 2000);
11
- const RETIRE_GRACE_MS = Number(process.env.MODWRIGHT_RETIRE_GRACE_MS ?? 30_000);
11
+ const RETIRE_GRACE_MS = Number(process.env.MODWRIGHT_RETIRE_GRACE_MS ?? 15 * 60_000);
12
12
  const INIT_TIMEOUT_MS = 20_000;
13
13
  const RESTART_BASE_MS = Number(process.env.MODWRIGHT_RESTART_BASE_MS ?? 500);
14
14
  const RESTART_MAX_DELAY_MS = 30_000;
@@ -68,15 +68,31 @@ class ServerProcess {
68
68
  this.child.on("exit", () => {
69
69
  this.exited = true;
70
70
  });
71
+ this.child.stdin.on("error", (err) => {
72
+ this.stdinBroken = true;
73
+ log(`server generation ${this.generation}: stdin ${err.code ?? err.message}; treating it as gone`);
74
+ });
75
+ this.child.on("error", (err) => {
76
+ this.exited = true;
77
+ log(`server generation ${this.generation}: ${err.message}`);
78
+ });
71
79
  }
80
+ stdinBroken = false;
72
81
  onMessage(listener) {
73
82
  this.listeners.add(listener);
74
83
  return () => this.listeners.delete(listener);
75
84
  }
76
85
  send(raw) {
77
- if (this.exited || !this.child.stdin || this.child.stdin.destroyed)
86
+ if (this.exited || this.stdinBroken || !this.child.stdin || this.child.stdin.destroyed)
78
87
  return false;
79
- this.child.stdin.write(`${raw}\n`);
88
+ try {
89
+ this.child.stdin.write(`${raw}\n`);
90
+ }
91
+ catch (err) {
92
+ this.stdinBroken = true;
93
+ log(`server generation ${this.generation}: write failed (${err.message}); treating it as gone`);
94
+ return false;
95
+ }
80
96
  return true;
81
97
  }
82
98
  kill() {
@@ -100,7 +116,6 @@ class Supervisor {
100
116
  swapQueued = false;
101
117
  closing = false;
102
118
  initializeRaw;
103
- initializeId;
104
119
  initializedSeen = false;
105
120
  routes = new Map();
106
121
  serverRequests = new Map();
@@ -135,7 +150,6 @@ class Supervisor {
135
150
  const active = this.active;
136
151
  if (msg.method === "initialize" && msg.id !== undefined && msg.id !== null) {
137
152
  this.initializeRaw = raw;
138
- this.initializeId = msg.id;
139
153
  }
140
154
  else if (msg.method === "notifications/initialized") {
141
155
  this.initializedSeen = true;
@@ -157,7 +171,7 @@ class Supervisor {
157
171
  }
158
172
  }
159
173
  }
160
- if (!active || active.exited) {
174
+ const answerNotRunning = () => {
161
175
  if (msg.id !== undefined && msg.id !== null && msg.method !== undefined) {
162
176
  const response = {
163
177
  jsonrpc: "2.0",
@@ -171,14 +185,23 @@ class Supervisor {
171
185
  };
172
186
  process.stdout.write(`${JSON.stringify(response)}\n`);
173
187
  }
188
+ };
189
+ if (!active || active.exited) {
190
+ answerNotRunning();
174
191
  return;
175
192
  }
176
- if (msg.id !== undefined && msg.id !== null && msg.method !== undefined) {
177
- const key = idKey(msg.id);
193
+ const key = msg.id !== undefined && msg.id !== null && msg.method !== undefined ? idKey(msg.id) : undefined;
194
+ if (key !== undefined) {
178
195
  this.routes.set(key, active);
179
196
  active.pending.add(key);
180
197
  }
181
- active.send(raw);
198
+ if (!active.send(raw)) {
199
+ if (key !== undefined) {
200
+ this.routes.delete(key);
201
+ active.pending.delete(key);
202
+ }
203
+ answerNotRunning();
204
+ }
182
205
  }
183
206
  fromServer(server, msg, raw) {
184
207
  if (msg.id !== undefined && msg.id !== null && msg.method === undefined) {
@@ -285,6 +308,7 @@ class Supervisor {
285
308
  }
286
309
  if (this.initializedSeen) {
287
310
  process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}\n`);
311
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/prompts/list_changed" })}\n`);
288
312
  }
289
313
  }
290
314
  finally {
package/dist/server.js CHANGED
@@ -3,10 +3,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  import { pathExists, tail } from "./core/fsutil.js";
6
- import { describeProject, findProjectFile, loadProject } from "./core/project/index.js";
6
+ import { describeProject, findProjectFile, loadProject, locateProjectFile } from "./core/project/index.js";
7
7
  import { userDataDir } from "./core/home.js";
8
8
  import { applyDataFetch, applyManagedInstall, checkToolchain, featureReadiness, locateTool, planDataFetch, planDataMemory, planManagedInstall, planToolMemory, readinessSummary, TOOL_SPECS, toolsToLocate } from "./core/toolchain/index.js";
9
9
  import { loadUserConfig } from "./core/userconfig.js";
10
+ import { describeNeeds, isProjectTrusted, planTrust, projectTrustNeeds, trustRefusal } from "./core/trust.js";
10
11
  import { runCompat } from "./core/compat/check.js";
11
12
  import { SignatureRegistry, triageLogs } from "./core/logs/index.js";
12
13
  import { buildContext, createValidatorRegistry, runValidators } from "./core/validate/index.js";
@@ -16,12 +17,13 @@ import { armBridge, bridgeCandidateDirs, bridgeStatus, cetLogCandidates, listPen
16
17
  import { runTestPlan } from "./core/testplan/index.js";
17
18
  import { applyBuildLedgerHook, applyClaimTransition, applyDecisionRecord, applyValidateLedgerHook, checkLedger, claimsFilePath, listClaims, loadClaims, loadDecisions, saveClaims, saveDecisions, showClaim, } from "./core/ledger/index.js";
18
19
  import { IndexStore, defaultCacheDir, findLegacyIndex, indexFile, legacyIndexHint, loadKnowledge, resolveIndexFile, searchFacts, } from "./core/knowledge/index.js";
19
- import { pathExists as fileExists } from "./core/fsutil.js";
20
20
  import { executePlan, listBackups, restoreBackup } from "./core/safety/index.js";
21
21
  import { readTextIfExists } from "./core/fsutil.js";
22
22
  import * as path from "node:path";
23
23
  import { promises as fs } from "node:fs";
24
24
  import { crossCheckIdentities, findConflicts, listMods, sharedIdentities } from "./core/mods.js";
25
+ import { buildOwnershipIndex, lookupOwnership, managerConflicts } from "./core/ownership/index.js";
26
+ import { collectWrites, contestedKeys, duplicateCopies, whatTouches, whoTouches } from "./core/writes/index.js";
25
27
  import { resolveLogs, resolveRoots } from "./core/surfaceutil.js";
26
28
  import { SURFACES, getSurface, installGameVersion, resolveInstall, surfaceIds } from "./surfaces/index.js";
27
29
  import { CONVERTERS, convert as runConvert, defaultLocateTool as defaultTextLocateTool, extractTemplate, fieldDiff, getTemplate, invalidateTemplate, listTemplates, } from "./core/text/index.js";
@@ -183,8 +185,12 @@ server.registerTool("find_conflicts", {
183
185
  installPath: installPathArg,
184
186
  rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
185
187
  limit: z.number().int().positive().default(100).describe("Max conflicts to return."),
188
+ mo2Instance: z
189
+ .string()
190
+ .optional()
191
+ .describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
186
192
  },
187
- }, async ({ game, installPath, rootIds, limit }) => {
193
+ }, async ({ game, installPath, rootIds, limit, mo2Instance }) => {
188
194
  const surface = getSurface(game);
189
195
  if (!surface)
190
196
  return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
@@ -202,10 +208,22 @@ server.registerTool("find_conflicts", {
202
208
  identityUnreadableCount = scanned.unreadable.length;
203
209
  identityUnreadable = scanned.unreadable.slice(0, IDENTITY_UNREADABLE_LIMIT);
204
210
  }
211
+ let managed;
212
+ if (surface.managedDataDir !== undefined) {
213
+ const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
214
+ const mo2 = index.sources.find((src) => src.id === "mo2");
215
+ if (mo2?.active) {
216
+ const found = managerConflicts(index, limit);
217
+ managed = { source: mo2.detail, total: found.total, conflicts: found.conflicts };
218
+ if (found.total > limit)
219
+ managed.truncated = true;
220
+ }
221
+ }
205
222
  return json({
206
223
  game: surface.id,
207
224
  modsAnalyzed: mods.length,
208
225
  conflictCount: conflicts.length,
226
+ managerConflicts: managed,
209
227
  skippedRoots,
210
228
  suppressedNoiseFiles,
211
229
  truncated: conflicts.length > limit,
@@ -215,10 +233,108 @@ server.registerTool("find_conflicts", {
215
233
  identityUnreadable,
216
234
  identityUnreadableTruncated: identityUnreadableCount > IDENTITY_UNREADABLE_LIMIT ? true : undefined,
217
235
  note: "File-level analysis, and only for roots where mods share one deploy tree. " +
218
- "See skippedRoots for what was excluded and why.",
236
+ "See skippedRoots for what was excluded and why." +
237
+ (managed
238
+ ? " managerConflicts are between Mod Organizer 2's mod folders, each with the winner the game sees and the mods it overrides."
239
+ : ""),
219
240
  guidance: surface.guidance,
220
241
  });
221
242
  });
243
+ server.registerTool("owner_of", {
244
+ title: "Which mod owns this file",
245
+ description: "Say which installed mod a file comes from, and which copy the game actually sees when several provide it. " +
246
+ "Accepts absolute paths, paths relative to the game folder, a trailing part of a path (SKSE/Plugins/foo.dll), " +
247
+ "or a bare file name — e.g. a DLL named in a crash log, a path from triage_logs. Sources: the game's own mod " +
248
+ "roots (a mod folder owns what is under it), Mod Organizer 2 (the active profile's mod folders in priority " +
249
+ "order, plus Overwrite; for games where the mapping is known), and Vortex deployment manifests. Each result " +
250
+ "says how the winner was decided; `sources` says what each source could see, and `unattributedRoots` lists " +
251
+ "loose-file folders whose files no source can attribute. Files inside packed archives are not indexed yet.",
252
+ inputSchema: {
253
+ game: gameArg,
254
+ installPath: installPathArg,
255
+ paths: z.array(z.string().min(1)).min(1).max(50).describe("Files to attribute."),
256
+ mo2Instance: z
257
+ .string()
258
+ .optional()
259
+ .describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
260
+ limit: z.number().int().positive().max(200).default(20).describe("Max matches per path (a bare file name can match many)."),
261
+ },
262
+ }, async ({ game, installPath, paths, mo2Instance, limit }) => {
263
+ const surface = getSurface(game);
264
+ if (!surface)
265
+ return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
266
+ const install = await resolveInstall(surface, installPath);
267
+ if (!install)
268
+ return fail(`No install detected for ${surface.name}.`);
269
+ const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
270
+ return json({
271
+ game: surface.id,
272
+ install: install.root,
273
+ sources: index.sources,
274
+ indexedPaths: index.entries.size,
275
+ unattributedRoots: index.unattributedRoots.length > 0 ? index.unattributedRoots : undefined,
276
+ results: paths.map((p) => lookupOwnership(index, p, limit)),
277
+ note: "Owners come only from what is on disk: a mod folder, a manager's own records. Nothing is inferred from " +
278
+ "file names. A path no mod claims is reported as such, with the likely reasons.",
279
+ });
280
+ });
281
+ server.registerTool("who_touches", {
282
+ title: "Which mods write this record or asset",
283
+ description: "Read every installed mod's data and report who writes a given key — at the level the game merges on, not the " +
284
+ "file level: a TweakDB record or flat (Cyberpunk 2077, TweakXL YAML and .tweak files), a stats entry or field " +
285
+ "(Baldur's Gate 3, inside paks and unpacked mods), an asset or data entry/field (Stardew Valley Content Patcher " +
286
+ "packs, Includes followed). Give `key` for who writes it (a record returns its flats too; an asset its entries), " +
287
+ "`mod` for everything one mod writes and where another mod writes the same key, or neither for every key two or " +
288
+ "more mods contend for. This says who touches a key, not who wins: the winner depends on the game's load order. " +
289
+ "Writes behind a Content Patcher When, or with {{tokens}}, are marked conditional.",
290
+ inputSchema: {
291
+ game: gameArg,
292
+ installPath: installPathArg,
293
+ key: z.string().min(1).optional().describe("A record, flat, stats entry, asset or entry, e.g. Items.Preset_Masamune_Default, stats:Projectile_Fireball, Data/Objects#128."),
294
+ mod: z.string().min(1).optional().describe("A mod (or unit) id or name, for what it writes."),
295
+ rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
296
+ limit: z.number().int().positive().max(500).default(50).describe("Max keys (or writes per mod) to return."),
297
+ },
298
+ }, async ({ game, installPath, key, mod, rootIds, limit }) => {
299
+ const surface = getSurface(game);
300
+ if (!surface)
301
+ return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
302
+ const install = await resolveInstall(surface, installPath);
303
+ if (!install)
304
+ return fail(`No install detected for ${surface.name}.`);
305
+ if (!surface.extractWrites) {
306
+ return json({
307
+ game: surface.id,
308
+ supported: false,
309
+ note: `No write-set extraction for ${surface.name} yet (supported: Cyberpunk 2077 tweaks, Baldur's Gate 3 stats, Stardew Valley Content Patcher).`,
310
+ });
311
+ }
312
+ const collection = await collectWrites(surface, install, rootIds ? { rootIds } : {});
313
+ const { errors, ...coverage } = collection.coverage;
314
+ const copies = duplicateCopies(collection);
315
+ const result = key
316
+ ? { mode: "key", ...whoTouches(collection, key, limit) }
317
+ : mod
318
+ ? { mode: "mod", query: mod, writers: whatTouches(collection, mod, limit) }
319
+ : { mode: "contested", ...contestedKeys(collection, limit) };
320
+ return json({
321
+ game: surface.id,
322
+ install: install.root,
323
+ coverage: {
324
+ ...coverage,
325
+ writers: collection.writers.length,
326
+ errorCount: errors.length,
327
+ errors: errors.slice(0, 25),
328
+ },
329
+ ...(copies.length > 0 ? { duplicateCopies: copies } : {}),
330
+ ...result,
331
+ note: "Static: read from the mods' files, never from the running game. 'contested' means two or more mods write the " +
332
+ "key and not only by appending; which one the game ends up with depends on load order, which this tool does " +
333
+ "not decide. A mod the game does not load (marked 'inactive') is listed but never contests, and copies of one " +
334
+ "mod ('duplicateCopies') count once: the game loads one of them. Code that writes at runtime (redscript, CET, " +
335
+ "SMAPI C# mods, Script Extender Lua) is not seen here.",
336
+ });
337
+ });
222
338
  server.registerTool("load_order", {
223
339
  title: "Read load order",
224
340
  description: "Read the game's explicit load order and cross-check it against what is " +
@@ -340,9 +456,7 @@ server.registerTool("project_info", {
340
456
  }, async ({ path: start }) => {
341
457
  const from = start ?? process.cwd();
342
458
  try {
343
- const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json")
344
- ? from
345
- : await findProjectFile(from);
459
+ const file = await locateProjectFile(from);
346
460
  if (!file) {
347
461
  return json({
348
462
  found: false,
@@ -368,9 +482,11 @@ server.registerTool("check_toolchain", {
368
482
  "with data=<id> fetches a data folder ModWright can fetch (Stardew Valley's wiki pages) and remembers it; " +
369
483
  "action 'remember' records a tool's path in the " +
370
484
  "per-user config so every session finds it (no environment variable, no restart); " +
371
- "'forget' drops it. Both are dry-run unless mode is 'apply'.",
485
+ "'forget' drops it. 'trust' records a project root (projectPath) as allowed to run its own toolchain overrides and " +
486
+ "exec build steps; until then an applied build, convert, template extract or index build that would run them is " +
487
+ "refused. 'untrust' removes it. All of these are dry-run unless mode is 'apply'.",
372
488
  inputSchema: {
373
- action: z.enum(["check", "remember", "forget", "install"]).default("check").describe("check (default) reports; remember/forget change the per-user config; install fetches a pinned tool into ModWright's tools folder (dry-run shows the file, source, size and sha256 first), or with data a fetchable data folder (dry-run lists every URL first)."),
489
+ action: z.enum(["check", "remember", "forget", "install", "trust", "untrust"]).default("check").describe("check (default) reports; remember/forget change the per-user config; trust/untrust change the trusted-projects list for projectPath; install fetches a pinned tool into ModWright's tools folder (dry-run shows the file, source, size and sha256 first), or with data a fetchable data folder (dry-run lists every URL first)."),
374
490
  game: gameArg.optional().describe("Restrict to tools used by this game (check only)."),
375
491
  projectPath: z
376
492
  .string()
@@ -380,9 +496,47 @@ server.registerTool("check_toolchain", {
380
496
  toolPath: z.string().optional().describe("remember: absolute path to the executable, or the folder that directly holds it."),
381
497
  data: z.string().optional().describe("remember/forget/install: a data folder id instead of a tool, e.g. 'valheim.jotunn' (a Jotunn checkout) or 'stardewvalley.wiki' (saved wiki pages); check_toolchain game=<id> lists the ones a game reads."),
382
498
  dataPath: z.string().optional().describe("remember with data: absolute path to that folder. install with data: where to save it (default: ModWright's per-user data folder)."),
383
- mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("remember/forget: dry-run shows the config change; apply writes it (backing up the old file)."),
499
+ mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("remember/forget/trust/untrust: dry-run shows the config change; apply writes it (backing up the old file)."),
384
500
  },
385
501
  }, async ({ action, game, projectPath, tool, toolPath, data, dataPath, mode }) => {
502
+ if (action === "trust" || action === "untrust") {
503
+ if (tool || toolPath || data || dataPath)
504
+ return fail(`${action} takes projectPath only.`);
505
+ if (!projectPath)
506
+ return fail(`${action} needs projectPath: the mod project's modwright.json or its folder.`);
507
+ let resolved;
508
+ try {
509
+ resolved = await resolveProject(projectPath);
510
+ }
511
+ catch (error) {
512
+ return fail(`Could not load project: ${error.message}`);
513
+ }
514
+ const planned = await planTrust(action, resolved.root, resolved.project.name);
515
+ if (!planned.ok)
516
+ return fail(planned.error);
517
+ const report = await executePlan(planned.plan, {
518
+ mode,
519
+ confidence: { level: "high", assumptions: ["The trusted-projects list is ModWright's own file; nothing in a game or a project is touched."] },
520
+ });
521
+ const needs = projectTrustNeeds(resolved.project);
522
+ return json({
523
+ action,
524
+ project: resolved.project.name,
525
+ root: planned.root,
526
+ mode,
527
+ applied: report.applied,
528
+ file: planned.file,
529
+ runs: { exec: needs.exec, toolchain: Object.fromEntries(needs.toolchain.map((id) => [id, resolved.project.toolchain?.[id]])) },
530
+ ...(planned.previous ? { previous: planned.previous } : {}),
531
+ operations: report.operations,
532
+ ...(report.backupDir ? { backupDir: report.backupDir } : {}),
533
+ note: report.applied
534
+ ? action === "trust"
535
+ ? `Trusted. Applied builds, converts, extracts and index builds may now run this project's exec steps and toolchain overrides.`
536
+ : `No longer trusted. Its exec steps and toolchain overrides are refused until it is trusted again.`
537
+ : `Dry run: nothing written. Read what it runs (runs) first; repeat with mode 'apply' to ${action} it.`,
538
+ });
539
+ }
386
540
  if ((action === "remember" || action === "forget") && data) {
387
541
  if (tool)
388
542
  return fail("Give either tool or data, not both.");
@@ -501,9 +655,12 @@ server.registerTool("check_toolchain", {
501
655
  }
502
656
  let projectOverrides;
503
657
  let project;
658
+ let projectRoot;
504
659
  if (projectPath) {
505
660
  try {
506
- project = (await loadProject(projectPath)).project;
661
+ const loaded = await loadProject(projectPath);
662
+ project = loaded.project;
663
+ projectRoot = loaded.root;
507
664
  projectOverrides = project.toolchain;
508
665
  }
509
666
  catch (error) {
@@ -521,11 +678,13 @@ server.registerTool("check_toolchain", {
521
678
  if (spec && !located.has(id))
522
679
  located.set(id, await locateTool(spec, { projectOverrides, probe: "trusted", userTools: userConfig.config.tools }));
523
680
  }
524
- const indexBuilt = surface.index ? await fileExists(resolveIndexFile(surface.id).file) : undefined;
681
+ const indexBuilt = surface.index ? await pathExists(resolveIndexFile(surface.id).file) : undefined;
525
682
  features = featureReadiness(surface, [...located.values()], { ...(project ? { project } : {}), ...(indexBuilt !== undefined ? { indexBuilt } : {}), userData: userConfig.config.data });
526
683
  }
684
+ const trusted = projectRoot ? await isProjectTrusted(projectRoot) : undefined;
527
685
  return json({
528
686
  game: surfaceId,
687
+ ...(projectRoot ? { project: { root: projectRoot, trusted, ...(trusted ? {} : { note: "Not trusted: an applied build, convert, extract or index build refuses to run its exec steps or toolchain overrides until check_toolchain action=trust." }) } } : {}),
529
688
  ...(features ? { summary: readinessSummary(features), features } : {}),
530
689
  found: tools.filter((t) => t.found).length,
531
690
  missing: tools.filter((t) => !t.found).map((t) => t.id),
@@ -556,7 +715,11 @@ server.registerTool("triage_logs", {
556
715
  "which failed and why, with the first error's explanation and usual fix. Pass the " +
557
716
  "returned offsets and offsetKeys back as offsets/offsetKeys for incremental triage after " +
558
717
  "the next launch (the keys let a re-created log of identical size be read again). " +
559
- "Stale and zero-byte logs are reported as such rather than trusted.",
718
+ "Stale and zero-byte logs are reported as such rather than trusted. Where a framework " +
719
+ "logs who did what (SMAPI's 'X edited Data/Objects', TweakXL's file read order, ArchiveXL " +
720
+ "merges and localization overwrites, Red Hot Tools' missing resources), the verdict's " +
721
+ "`attribution` groups those lines by subject, contested subjects first: the log's own answer " +
722
+ "to 'which mod is doing this'. Pass attributionFor to narrow it to one asset, record or mod.",
560
723
  inputSchema: {
561
724
  game: gameArg,
562
725
  installPath: installPathArg,
@@ -574,15 +737,20 @@ server.registerTool("triage_logs", {
574
737
  .describe("Per log id file identities (offsetKeys) from the same previous result."),
575
738
  maxLines: z.number().int().positive().max(20000).optional(),
576
739
  includeFindings: z.boolean().default(true),
740
+ attributionFor: z
741
+ .string()
742
+ .optional()
743
+ .describe("Keep only attribution entries whose subject, actor or content pack contains this text " +
744
+ "(case-insensitive): an asset or record for 'who touched this', a mod name for 'what did it touch'."),
577
745
  },
578
- }, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings }) => {
746
+ }, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings, attributionFor }) => {
579
747
  const surface = getSurface(game);
580
748
  if (!surface)
581
749
  return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
582
750
  const install = await resolveInstall(surface, installPath);
583
751
  if (!install)
584
752
  return fail(`No install detected for ${surface.name}.`);
585
- const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines });
753
+ const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines }, { attributionFor });
586
754
  return json({
587
755
  ...verdict,
588
756
  signatureCount: registry.forGame(surface.id).length,
@@ -632,7 +800,7 @@ server.registerTool("load_order_diff", {
632
800
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
633
801
  let snapshotDir = path.join(userDataDir(), "snapshots");
634
802
  if (projectPath) {
635
- const projectFile = (await pathExists(projectPath)) && projectPath.toLowerCase().endsWith(".json") ? projectPath : await findProjectFile(projectPath);
803
+ const projectFile = await locateProjectFile(projectPath);
636
804
  if (!projectFile)
637
805
  return fail(`No modwright.json found at or above ${projectPath}.`);
638
806
  snapshotDir = path.join(path.dirname(path.resolve(projectFile)), ".modwright", "snapshots");
@@ -686,7 +854,7 @@ function getStore(gameId, reopen = false) {
686
854
  legacyStores.set(gameId, legacy);
687
855
  else
688
856
  legacyStores.delete(gameId);
689
- pending = fileExists(file).then((exists) => (exists ? IndexStore.open(file) : undefined));
857
+ pending = pathExists(file).then((exists) => (exists ? IndexStore.open(file) : undefined));
690
858
  stores.set(gameId, pending);
691
859
  }
692
860
  return pending;
@@ -867,10 +1035,15 @@ server.registerTool("build_index", {
867
1035
  let toolOverrides;
868
1036
  if (projectPath) {
869
1037
  try {
870
- const file = (await pathExists(projectPath)) && projectPath.toLowerCase().endsWith(".json") ? projectPath : await findProjectFile(projectPath);
1038
+ const file = await locateProjectFile(projectPath);
871
1039
  if (!file)
872
1040
  return fail(`No modwright.json found at or above ${projectPath}.`);
873
- toolOverrides = (await loadProject(file)).project.toolchain;
1041
+ const loaded = await loadProject(file);
1042
+ toolOverrides = loaded.project.toolchain;
1043
+ const used = Object.keys(toolOverrides ?? {}).filter((id) => TOOL_SPECS.find((s) => s.id === id)?.usedBy.includes(surface.id));
1044
+ if (used.length > 0 && !(await isProjectTrusted(loaded.root))) {
1045
+ return fail(trustRefusal(loaded.root, `its modwright.json overrides the tool path(s) for ${used.join(", ")}`));
1046
+ }
874
1047
  }
875
1048
  catch (error) {
876
1049
  return fail(`Could not load project for tool overrides: ${error.message}`);
@@ -881,7 +1054,7 @@ server.registerTool("build_index", {
881
1054
  if (legacy) {
882
1055
  return json({ game: surface.id, rebuilt: false, file: legacy.file, legacyIndex: legacy.file, note: `${legacyIndexHint(surface.id, legacy)} lookup already uses it. Pass force to build a fresh index in the new location instead.` });
883
1056
  }
884
- if (!force && (await fileExists(file))) {
1057
+ if (!force && (await pathExists(file))) {
885
1058
  const existing = await surface.index.info(install);
886
1059
  if (!existing.stale)
887
1060
  return json({ ...existing, note: "Index already exists; pass force to rebuild." });
@@ -960,7 +1133,10 @@ server.registerTool("validate", {
960
1133
  });
961
1134
  const findings = includeInfo ? report.findings : report.findings.filter((finding) => finding.severity !== "info");
962
1135
  const blockingFiles = report.findings.filter((f) => f.severity === "block" && !f.suppressedBy).map((f) => f.file);
963
- const ledger = await applyValidateLedgerHook(resolved.root, blockingFiles, changedFiles, "validate");
1136
+ const narrowed = Boolean(ids?.length) || Boolean(groups?.length) || cheapOnly;
1137
+ const ledger = narrowed
1138
+ ? { skipped: true, applied: [], refused: [], reason: "a run narrowed by rule, groups or cheapOnly never promotes a claim; run the full set" }
1139
+ : await applyValidateLedgerHook(resolved.root, blockingFiles, changedFiles, "validate");
964
1140
  return json({
965
1141
  ...report,
966
1142
  findings: findings.slice(0, 200),
@@ -1025,7 +1201,13 @@ server.registerTool("build", {
1025
1201
  const ledger = mode === "apply" && !result.failed && result.manifestPath
1026
1202
  ? await applyBuildLedgerHook(resolved.root, result.manifestPath, "build")
1027
1203
  : { skipped: true, applied: [], refused: [] };
1028
- return json({ ...result, summary, ledger });
1204
+ let trust;
1205
+ if (mode === "dry-run") {
1206
+ const what = describeNeeds(projectTrustNeeds(resolved.project));
1207
+ if (what && !(await isProjectTrusted(resolved.root)))
1208
+ trust = { trusted: false, note: trustRefusal(resolved.root, what) };
1209
+ }
1210
+ return json({ ...result, summary, ledger, ...(trust ? { trust } : {}) });
1029
1211
  });
1030
1212
  const RELOAD_PATH_LIMIT = 200;
1031
1213
  function trimReload(reload) {
@@ -1113,13 +1295,19 @@ server.registerTool("rollback", {
1113
1295
  "purpose, entry count, how many of those entries are junction pointers rather than content, and total " +
1114
1296
  "size. With a backupId it restores that backup: dry-run by default, and every file's sha256 is re-checked " +
1115
1297
  "against the manifest immediately before it is written back. A junction is recreated as a junction, never " +
1116
- "as a copy of what it pointed at.",
1298
+ "as a copy of what it pointed at. A restore writes only under the project and the game's detected mod roots unless " +
1299
+ "allowOutsideRoots is set.",
1117
1300
  inputSchema: {
1118
1301
  path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
1119
1302
  backupId: z.string().optional().describe("The stamp naming one backup directory under .modwright/backups (from this tool's own listing). Omit to list."),
1120
1303
  mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run reports what would be restored without writing a byte."),
1304
+ installPath: installPathArg,
1305
+ allowOutsideRoots: z
1306
+ .boolean()
1307
+ .default(false)
1308
+ .describe("Restore entries outside the project and the game's mod roots too. Off by default: a backup that names any other path is refused whole."),
1121
1309
  },
1122
- }, async ({ path: start, backupId, mode }) => {
1310
+ }, async ({ path: start, backupId, mode, installPath, allowOutsideRoots }) => {
1123
1311
  const resolved = await projectOrFail(start);
1124
1312
  if ("isError" in resolved)
1125
1313
  return resolved;
@@ -1141,17 +1329,30 @@ server.registerTool("rollback", {
1141
1329
  if (!(await pathExists(backupDir))) {
1142
1330
  return fail(`No backup "${backupId}" under ${backupRoot}. Call rollback with no backupId to list what is there.`);
1143
1331
  }
1332
+ let confineTo;
1333
+ let noInstall = false;
1334
+ if (!allowOutsideRoots) {
1335
+ const surface = getSurface(resolved.project.game);
1336
+ const install = surface ? await resolveInstall(surface, installPath).catch(() => undefined) : undefined;
1337
+ noInstall = !install;
1338
+ confineTo = [resolved.root, ...(surface && install ? surface.modRoots(install).map((r) => r.path) : [])];
1339
+ }
1144
1340
  try {
1145
- const report = await restoreBackup(backupDir, { mode });
1146
- return json({ ...report, backupId, backupDir });
1341
+ const report = await restoreBackup(backupDir, { mode, ...(confineTo ? { confineTo } : {}) });
1342
+ const operations = report.operations.map(({ op, ...rest }) => ({
1343
+ ...rest,
1344
+ op: "content" in op ? { kind: op.kind, path: op.path, bytes: rest.sizeBytes } : op,
1345
+ }));
1346
+ return json({ ...report, operations, backupId, backupDir });
1147
1347
  }
1148
1348
  catch (error) {
1149
- return fail(`rollback failed: ${error.message}`);
1349
+ const hint = noInstall ? " The game's install was not detected, so only the project counts; pass installPath to add its mod roots." : "";
1350
+ return fail(`rollback failed: ${error.message}${hint}`);
1150
1351
  }
1151
1352
  });
1152
1353
  server.registerTool("reload_plan", {
1153
1354
  title: "Classify what a set of deployed paths needs to take effect",
1154
- description: "Map deployed paths to a reload recommendation — hot, hot-degraded, save-reload, restart or unknown — " +
1355
+ description: "Map deployed paths to a reload recommendation — hot, hot-degraded, save-reload, restart or unknown (none when nothing was written) — " +
1155
1356
  "using the project's own reload.rules first and then the per-surface table, and reduce them to the single " +
1156
1357
  "most conservative answer with its caveats. An unmatched path is reported as unknown ('restart to be sure'), " +
1157
1358
  "never promoted. ModWright never launches the game and never sends a reload command; this is a " +
@@ -1234,12 +1435,18 @@ server.registerTool("bridge", {
1234
1435
  timeoutSeconds: z.number().positive().max(300).optional().describe("'poll': override the computed budget; still capped at 300s."),
1235
1436
  },
1236
1437
  }, async ({ action, game, installPath, path: start, mode, probes, armForSeconds, grant, allowMutate, windowSeconds, planId, planPath, runId, id, nonce, probeCount, timeoutSeconds }) => {
1438
+ const planFields = { planId, planPath, runId };
1439
+ const givenPlan = Object.entries(planFields).filter(([, v]) => v !== undefined);
1440
+ if (givenPlan.length > 0 && givenPlan.length < 3) {
1441
+ const missing = Object.entries(planFields).filter(([, v]) => v === undefined).map(([k]) => k);
1442
+ return fail(`planId, planPath and runId go together; ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} missing.`);
1443
+ }
1237
1444
  let projectGame;
1238
1445
  let projectRoot;
1239
1446
  if (start) {
1240
1447
  try {
1241
1448
  const from = start;
1242
- const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json") ? from : await findProjectFile(from);
1449
+ const file = await locateProjectFile(from);
1243
1450
  if (!file)
1244
1451
  return fail(`No modwright.json found at or above ${from}.`);
1245
1452
  const loaded = await loadProject(file);
@@ -1540,7 +1747,7 @@ const TEMPLATE_SURFACES = ["baldursgate3", "cyberpunk2077"];
1540
1747
  const TEMPLATE_PERMISSIONS = ["never-redistribute", "maintainer-own", "declared-dependency"];
1541
1748
  async function resolveProject(start) {
1542
1749
  const from = start ?? process.cwd();
1543
- const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json") ? from : await findProjectFile(from);
1750
+ const file = await locateProjectFile(from);
1544
1751
  if (!file)
1545
1752
  throw new NoProjectError(`No modwright.json found at or above ${from}.`);
1546
1753
  return loadProject(file);
@@ -1566,15 +1773,24 @@ server.registerTool("convert", {
1566
1773
  },
1567
1774
  }, async ({ conversionId, source, dest, mode, verifyRoundTrip, path: projectPath }) => {
1568
1775
  let overrides;
1569
- if (projectPath) {
1776
+ let projectTrust;
1777
+ let backupRoot;
1778
+ const projectStart = projectPath ?? (path.isAbsolute(dest) ? (await findProjectFile(path.dirname(dest))) : undefined);
1779
+ if (projectStart) {
1570
1780
  try {
1571
- overrides = (await resolveProject(projectPath)).project.toolchain;
1781
+ const resolved = await resolveProject(projectStart);
1782
+ backupRoot = path.join(resolved.root, ".modwright", "backups");
1783
+ if (projectPath) {
1784
+ overrides = resolved.project.toolchain;
1785
+ if (overrides && Object.keys(overrides).length > 0)
1786
+ projectTrust = { root: resolved.root, trusted: await isProjectTrusted(resolved.root) };
1787
+ }
1572
1788
  }
1573
1789
  catch {
1574
1790
  }
1575
1791
  }
1576
1792
  try {
1577
- const report = await runConvert(conversionId, source, dest, { mode, verifyRoundTrip, locateTool: defaultTextLocateTool(overrides, mode === "apply" ? "all" : "none") });
1793
+ const report = await runConvert(conversionId, source, dest, { mode, verifyRoundTrip, locateTool: defaultTextLocateTool(overrides, mode === "apply" ? "all" : "none", projectTrust), ...(backupRoot ? { backupRoot } : {}) });
1578
1794
  return json(report);
1579
1795
  }
1580
1796
  catch (error) {
@@ -1667,7 +1883,9 @@ server.registerTool("template", {
1667
1883
  catch (error) {
1668
1884
  return fail(`Could not load project: ${error.message}`);
1669
1885
  }
1670
- const locateTool = defaultTextLocateTool(resolved.project.toolchain, action === "extract" && mode === "apply" ? "all" : action === "extract" ? "none" : "trusted");
1886
+ const overrides = resolved.project.toolchain;
1887
+ const projectTrust = action === "extract" && overrides && Object.keys(overrides).length > 0 ? { root: resolved.root, trusted: await isProjectTrusted(resolved.root) } : undefined;
1888
+ const locateTool = defaultTextLocateTool(overrides, action === "extract" && mode === "apply" ? "all" : action === "extract" ? "none" : "trusted", projectTrust);
1671
1889
  const templateGameVersion = async (surfaceId) => {
1672
1890
  const gameSurface = getSurface(surfaceId);
1673
1891
  if (!gameSurface)
@@ -6,6 +6,7 @@ import { dedupeByRoot, logs, roots } from "../../core/surfaceutil.js";
6
6
  import { bg3Index } from "./index/index.js";
7
7
  import { scanModIdentities as scanBg3ModIdentities } from "./identity.js";
8
8
  import { SIGNATURES } from "./logs.js";
9
+ import { extractBg3Writes } from "./writes.js";
9
10
  import { describeModSettingsDiff, diffModSettings, parseModSettings } from "./modsettings.js";
10
11
  import { BRIDGE_VALIDATORS } from "./validators/bridge.js";
11
12
  import { FILE_VALIDATORS } from "./validators/files.js";
@@ -112,6 +113,8 @@ export const baldursgate3 = {
112
113
  };
113
114
  },
114
115
  logSignatures: SIGNATURES,
116
+ extractWrites: (mod, root) => extractBg3Writes(mod, root),
117
+ looseUnitOf: (rootId, rel) => (rootId === "loose" ? /^(?:Mods|Public|Generated\/Public)\/([^/]+)\//i.exec(rel)?.[1] : undefined),
115
118
  index: bg3Index,
116
119
  validators: [
117
120
  ...ITEM_VALIDATORS,
@@ -228,8 +228,9 @@ const osirisNamingConvention = {
228
228
  const base = path.posix.basename(lower(ctx.target.path), ".txt");
229
229
  if (!/^[a-z0-9]+(_[a-z0-9]+){2,}$/i.test(base)) {
230
230
  findings.push(finding(osirisNamingConvention.id, "info", ctx.target.path, `Goal file name '${path.basename(ctx.target.path)}' does not obviously follow the Act_Region_Situation convention. ` +
231
- `This is a naming convention, not a known build failure — no fact in knowledge/baldursgate3/ confirms a missing ` +
232
- `prefix disables the story component (still an open question), so this stays informational.`, osirisNamingConvention.evidence));
231
+ `This is a naming convention, not a known build failure: LSLib's story compiler only warns on a name missing ` +
232
+ `its DB/PROC/QRY prefix (bg3.dialogue.osiris-goals.prefix-convention-is-a-compiler-warning-in-lslib), ` +
233
+ `so this stays informational.`, osirisNamingConvention.evidence));
233
234
  }
234
235
  const text = stripBom(await ctx.readText(ctx.target.path)).replace(/\r\n/g, "\n");
235
236
  const badPrefixRe = /\b(?!DB_|QRY_|PROC_)([A-Za-z][A-Za-z0-9_]*)\s*\(/g;
@@ -482,7 +483,6 @@ const questAddQuestCloseDiscouraged = {
482
483
  return goal.questAddClose.map((occ) => finding(questAddQuestCloseDiscouraged.id, "info", targetPath, `${occ.name}(...) is used here. The documentation recommends relying on QuestUpdate instead of manually adding/closing quests.`, questAddQuestCloseDiscouraged.evidence, { line: occ.line }));
483
484
  },
484
485
  };
485
- const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
486
486
  function collectOrigins(doc) {
487
487
  const origins = [];
488
488
  for (const region of doc.regions) {